# Ionworks Agent Source: https://docs.ionworks.com/agents Chat with the Ionworks battery-modelling agent directly inside Studio — run simulations, fit models, and explore data without leaving the app. The Ionworks Agent is a battery-modelling analyst built into Ionworks Studio. It runs real electrochemical simulations, fits models, and explores the data in your project — all inside a chat panel that stays anchored to whatever page you are on. The Agent is hosted for you. There is nothing to install and no API key to configure — just open the chat and start asking. If you would rather work in your own coding agent, see [Bring your own agent](#bring-your-own-agent). ## When to use it Reach for the in-app Agent when you want to: * **Explore what you have** — "What cell specifications and measurements do I have in this project?" * **Run a simulation on the fly** — "Run a 1C discharge on one of my parameterized models and plot voltage vs time." * **Fit a model to a measurement** — "Fit an ECM to one of my cells and show the voltage overlay." * **Get context-aware help** — from a study, cell, or measurement page, ask "summarise this study" or "what's odd about this measurement?" without spelling out the ids. For scripted or repeatable work — CI, batch parameterization, anything you need to re-run unattended — use the [Python API client](/api-client) instead. ## Two ways to open it A chat bubble in the bottom-right corner of every dashboard page. Clicking it opens a panel over the current view — the agent knows which page you are on and can resolve references like "this study" or "this cell" without you naming ids. Open **Ionworks Agent** in the Studio sidebar for the full experience: a session list on the left, the active conversation on the right, and room for larger plots and code output. Both views talk to the same sessions — a conversation you started from the floating widget is available in the full Agent page, and vice versa. ## What the agent can do The agent has access to your project data through the same authenticated API your account uses, so every action respects your organization's row-level permissions. * **Run PyBaMM simulations** on your parameterized models and plot the results inline. * **Fit equivalent-circuit and physics-based models** to measurements you have already uploaded. * **Query your data** — list projects, cells, measurements, studies, models, and pipelines; open any of them by asking. * **Execute Python** in a sandboxed environment. Code runs as read-only notebook cells in the chat, complete with captured `stdout`, results, and images. Each cell is a fresh interpreter — nothing carries over between them, so the agent re-imports and rebuilds `client` on every call. * **Attach files** — drag any file up to 50 MB into the chat and ask the agent to inspect or process it. Attachments stay with the conversation, so later turns can reuse them without re-attaching. MATLAB `.mat` exports work except `-v7.3` (HDF5) saves, which the sandbox cannot read. * **Link back into the app** — when the agent points at a resource it renders a "Go to this page" button that navigates you there without tearing down the chat. * **Show you where things are** — ask how or where to do something and the agent takes you to the right page, then picks the explanation back up once you arrive. ## Page context When you open the floating widget from a page like `/projects//studies/` or a cell-instance measurements page, the agent receives a small structured note describing where you are — the page name and the ids of the project, study, cell spec, cell instance, measurement, model, or pipeline in view. This lets you say "this study" or "the measurement I'm looking at" and have the agent resolve it correctly, without pasting ids into the prompt. Only ids and page labels are sent — never URLs, query strings, or free-form page content. The backend re-sanitises the note before it reaches the model. ## Sessions Each conversation is its own session, persisted to your account so you can pick it up later or from another device. **Sessions are scoped to a project** — both views list only the sessions belonging to the project you are currently in. * The **floating widget** resumes where you left off: on reopening it restores the session you last used in that project, falling back to the most recently updated one. Start a fresh chat, or switch to an earlier session, from the widget's session menu. * The **full Agent page** shows that project's sessions in the left-hand list. Click one to resume, or start a new chat from the header. You can stop an in-flight turn at any time — the agent finishes the current tool step, then hands control back to you. ## Export a conversation as a notebook Every conversation in the full Agent page has an **Export notebook (.ipynb)** action. Downloading it gives you a Jupyter notebook where: * Every `run_python` step becomes a code cell, complete with captured outputs (stdout/stderr, result values, images, error tracebacks). * Your prompts and the agent's prose become markdown cells. * Inline plots become embedded images. The result is a complete record of the analysis the agent produced — open it in Jupyter to read, tweak, or hand it to a colleague. The export carries the conversation and its captured outputs, not the environment they ran in. To re-execute the cells you need your own Python environment with whatever the code imports installed — typically `ionworks` (see [Python API client](/api-client)) plus the usual scientific stack (`pybamm`, `numpy`, `pandas`, `matplotlib`). ## Example prompts Paste any of these into the chat to get a feel for what the agent will do. ```text Explore data theme={null} What cell specifications and measurements do I have in this project? Which of my cells have GITT measurements? ``` ```text Run a simulation theme={null} Run a 1C discharge on one of my parameterized models and plot voltage vs time. Sweep C-rate from 0.5C to 3C on my NMC/Graphite parameterized model and overlay the discharge curves. ``` ```text Fit a model theme={null} Fit an ECM to one of my cells and show the voltage overlay against the measured data. Fit a single-particle model to my rate-capability data on cell "NMC-A" and report the SoC-dependent parameters. ``` ```text Contextual (with page context) theme={null} Summarise this study. What's the SoC window covered by this measurement? Open the fitted parameterized model. ``` ## Requirements and limits * Available to any signed-in Ionworks Studio user. No extra install. * Simulations, fits, and data queries run against **your** project data under your account permissions — you will only see cells, measurements, and models you already have access to. * Python execution is stateless — every cell is a fresh interpreter, so variables do not carry from one to the next. Attached files and anything written to the working directory do persist for the life of the conversation, but not beyond it. * Very long conversations are truncated on the model side. Export to a notebook before starting a new session if you want to preserve the full record. ## Bring your own agent The **Ionworks Agentic Toolkit** teaches your own coding agent to drive Ionworks, so you can stay in it instead of switching to the in-app chat. It covers the full R\&D loop: data processing, validation, upload, cell and equipment management, model fitting, simulations, pipelines, and reporting. It is a folder of `SKILL.md` files calling the [Python API client](/api-client), so there is no server to run and nothing to keep in sync. Works with Claude Code, Cursor, Codex, Gemini CLI, and GitHub Copilot. ### Let your agent install it One paste, no decisions: your agent works out where its own skills live and does the rest. Create a key on your Studio **Account** page, then paste this to your agent: ````text Ask your agent theme={null} Install the Ionworks Agentic Toolkit for yourself, then use it. 1. Check that IONWORKS_API_KEY is set in my shell. If it is missing, stop and tell me to create one at https://app.ionworks.com/dashboard/account — do not try to mint one yourself. 2. Install the SDK, which ships the installer for the toolkit: ```bash uv add ionworks-api # or: pip install ionworks-api ``` 3. Install the skills for yourself. This defaults to the current project; add -g to install for all projects, and -a to name agents explicitly if it does not detect you: ```bash ionworks skills install ``` 4. Run `ionworks skills list` to confirm what landed, then invoke the `install` skill for the SDK's self-check. 5. Report what you did and anything left for me to do by hand. If the `ionworks` command is unavailable, fall back to fetching the archive directly and copying skills/* into your own skills directory: ```bash curl -fsSL -H "X-API-Key: $IONWORKS_API_KEY" \ https://api.ionworks.com/agent/skills.zip -o /tmp/ionworks-skills.zip rm -rf ~/ionworks-skills && unzip -q /tmp/ionworks-skills.zip -d ~ ``` ```` The endpoint requires authentication: an `X-API-Key` header (above) or a logged-in Studio session (the download button). An unauthenticated request gets a 401, so the command will not work without the key. #### Updating later `ionworks skills update` is all it takes, but the SDK should be upgraded alongside it since the skills track the current API. To hand that off: ````text Ask your agent theme={null} Update the Ionworks Agentic Toolkit you already have installed. 1. Record the version you have now: `ionworks skills list`. 2. Upgrade the SDK, since the skills track the current API: ```bash uv add --upgrade ionworks-api # or: pip install --upgrade ionworks-api ``` 3. Update the skills. Use the same scope you installed with — add -g if they live in the user-level directory rather than this project: ```bash ionworks skills update ``` This removes the skills from the previous install before copying the new set, so anything retired upstream does not linger. Do not hand-copy over the old files instead; that merges and leaves orphans behind. 4. Run `ionworks skills list` again and report the version you had, the version you now have, and anything that changed which affects how I should use these skills. ```` The [**Bring your own agent**](https://app.ionworks.com/dashboard/agent?tab=byo) tab shows the current toolkit version, so you can check what you are updating to. ### Or run the CLI yourself For CI, containers, or anyone who would rather type it than delegate: the Python SDK ships an `ionworks` command that fetches the toolkit and installs it into whichever agents it finds. Create a key on your Studio **Account** page first. ```bash theme={null} uv add ionworks-api # or: pip install ionworks-api export IONWORKS_API_KEY="iw_..." ionworks skills install # this project ionworks skills install -g # all projects ``` Scope and agent selection follow the same conventions as `npx skills`: project by default, `-g` for the user-level directory, `-a claude-code cursor` to name agents explicitly instead of auto-detecting. `ionworks skills list` shows what is installed and at which version. **Re-run it to update** — `ionworks skills update` is the same operation. Whichever you use, the skills from the previous install are removed before the new set lands, so a skill retired upstream cannot linger and keep being loaded. Skills you wrote yourself are left alone. ### Or install it by hand Open **Ionworks Agent** in the Studio sidebar, switch to the [**Bring your own agent**](https://app.ionworks.com/dashboard/agent?tab=byo) tab, and click **Download ionworks-skills.zip**. Unzip it somewhere stable: ```bash theme={null} unzip ~/Downloads/ionworks-skills.zip -d ~ ``` The archive carries its own `ionworks-skills/` folder, so this creates `~/ionworks-skills`. See [Updating by hand](#updating-by-hand) below for refreshing it later. Copy the skills into your agent's skills directory — `~/.claude/skills/` for Claude Code, `~/.cursor/skills/` for Cursor, `.github/skills/` for Copilot: ```bash theme={null} cp -r ~/ionworks-skills/skills/* ~/.claude/skills/ ``` Gemini CLI installs the unzipped folder as an extension (`gemini extensions install ~/ionworks-skills`); Codex installs it as a local plugin. The toolkit's own `README.md` has the exact commands for each. The skills call the Python client, so it needs to be installed in the environment your agent runs code in: ```bash theme={null} uv add ionworks-api # or: pip install ionworks-api ``` Create an API key from your Studio **Account** page and export it: ```bash theme={null} export IONWORKS_API_KEY="iw_..." ``` Ask your agent to use the `install` skill — it runs the SDK's self-check and reports anything still missing. #### Updating by hand Two copies need replacing: the checkout, and the skills you copied out of it into your agent. Delete rather than overwrite — unzipping or copying *over* the old files merges, so a skill retired upstream would linger and your agent would keep loading it. Clear the installed skills **before** refreshing the checkout: the list of what to remove comes from the old checkout, and a retired skill is no longer named in the new one. ```bash theme={null} # 1. remove what you installed last time, named from the OLD checkout for s in $(ls ~/ionworks-skills/skills); do rm -rf ~/.claude/skills/"$s"; done # 2. refresh the checkout and copy the new set in rm -rf ~/ionworks-skills unzip ~/Downloads/ionworks-skills.zip -d ~ cp -r ~/ionworks-skills/skills/* ~/.claude/skills/ # 3. the skills track the current API, so upgrade it too uv add --upgrade ionworks-api ``` Adjust the skills path for your agent. If you installed as a Gemini extension or Codex plugin, uninstall and reinstall it instead of copying files. Already unzipped it yourself? You can still hand off the rest — point your agent at `~/ionworks-skills/README.md` and ask it to follow the local-checkout install path for whichever agent it is running in. The download is tied to your Studio login, and the skills themselves carry no credentials — each one reads `IONWORKS_API_KEY` from the environment at run time, so everything your agent does still runs under your own account permissions. ## Related * [Python API client](/api-client) — the same SDK the agent uses; call it directly from your own scripts. * [Quickstart](/quickstart) — create a project, cell, and parameterized model so the agent has something to work with. # Python API client Source: https://docs.ionworks.com/api-client Install and configure the ionworks-api Python package: authentication, dataframe backend, retries, and sub-clients reference The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package provides a programmatic interface for managing resources, running simulations, submitting pipelines, and uploading data in Ionworks Studio. ## Installation Install the package from the repository: ```bash theme={null} pip install ionworks-api ``` ## Describing pipelines: `ionworks-schema` `ionworks-api` submits work and collects results. To *describe* a fit or a validation, you also want [`ionworks-schema`](/build/parameterize/overview), which provides the typed building blocks — `Pipeline`, `SimplePipeline`, `DataFit`, objectives, costs, and optimizers. ```bash theme={null} pip install ionworks-schema ``` ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks pipeline = iws.SimplePipeline( elements={ "fit": iws.DataFit( objectives={"cycle": iws.objectives.CurrentDriven( data_input="path/to/discharge.csv", )}, parameters={ "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", bounds=(1e-14, 1e-13), initial_value=2e-14, ), }, cost=iws.costs.RMSE(), ), }, name="Diffusivity fit", ) client = Ionworks() sp = client.simple_pipeline.create(pipeline, name=pipeline.name) ``` See the [Pipelines API how-to](/build/parameterize/api#simplepipeline) for polling and results, or [Which package do I need?](/which-package) if you are not sure which of these you want. ## Authentication Get your API key from the Ionworks account settings and configure it: ```python theme={null} from ionworks import Ionworks # Option 1: Environment variable (recommended) # Set IONWORKS_API_KEY in your shell environment before constructing the client client = Ionworks() # Option 2: Direct configuration client = Ionworks(api_key="your_key") # Option 3: Custom timeout and retry settings client = Ionworks( timeout=30, # Request timeout in seconds (default: 10) max_retries=3 # Max retries on failure (default: 5) ) ``` Never commit API keys to version control. Use environment variables or a secrets manager for credential management. Starting in `ionworks-api` 0.10.0, importing `ionworks` no longer auto-loads a `.env` file. Set `IONWORKS_API_KEY` in your shell environment, load the `.env` file yourself (for example with [python-dotenv](https://pypi.org/project/python-dotenv/)) before constructing the client, or pass `api_key=` explicitly to `Ionworks(...)`. ### Verify your API key Use `client.whoami()` to confirm which user and organization the configured API key resolves to. This is the recommended way to debug `401`/`403` errors, or to check why you're seeing data from the wrong organization. ```python theme={null} me = client.whoami() print(me["email"], me["authorized_organization"]) # alice@example.com {'id': 'org_abc123', 'name': 'Acme Battery'} ``` The response has two organization fields and the distinction matters: * `authorized_organization` — the org this request is **authorized as**. For SDK calls, this is the org the configured API key was issued for, and is the source of truth for permission checks on every request the client makes. It's `None` if no org context could be resolved. * `organizations` — the user's full membership list (every org they belong to). This is a different question and is **not** what permission checks run against. If the `id` or `name` in `authorized_organization` doesn't match what you expect, the wrong key is in use — regenerate one for the correct org from your [account settings](https://app.ionworks.com/dashboard/settings). ## Default project Most sub-clients (pipelines, studies, optimizations, cell specifications, ...) operate within a [project](/core-concepts/projects-studies). Rather than threading a `project_id` through every call, you can configure a default once on the client. Sub-client methods that take a `project_id` argument fall back to this default when one isn't passed explicitly. The client resolves the default in this order: 1. The `project_id=` argument passed to `Ionworks(...)`. 2. The `IONWORKS_PROJECT_ID` environment variable. 3. Otherwise, no default is set — methods that need a project will raise `ValueError` unless `project_id` is passed at the call site. ```python theme={null} # Option 1: Environment variable (recommended) # Set IONWORKS_PROJECT_ID in your environment or .env file client = Ionworks() # Option 2: Pass to the client constructor client = Ionworks(project_id="your-project-id") # Override on a per-call basis when needed client.study.list(project_id="other-project-id") ``` You can find your project ID in the URL of the project settings page: `https://app.ionworks.com/dashboard/projects//settings`. The `PROJECT_ID` environment variable is still accepted for backwards compatibility but is deprecated. Set `IONWORKS_PROJECT_ID` instead — using the old name emits a `DeprecationWarning` and will stop working in a future release. ### Environment variables The client reads the following variables from your shell environment when constructed. `.env` files are **not** loaded automatically — populate the environment yourself (for example by sourcing the file, or by calling [python-dotenv](https://pypi.org/project/python-dotenv/)) before constructing the client. | Variable | Required | Default | Description | | ---------------------------- | ------------------------ | -------------------------- | ------------------------------------------ | | `IONWORKS_API_KEY` | Yes | — | API key from your account settings. | | `IONWORKS_API_URL` | No | `https://api.ionworks.com` | API base URL. | | `IONWORKS_PROJECT_ID` | For project-scoped calls | — | Default project ID for sub-client methods. | | `IONWORKS_DATAFRAME_BACKEND` | No | `polars` | DataFrame backend: `polars` or `pandas`. | ## DataFrame backend By default, the client returns data as [polars](https://pola.rs/) DataFrames. You can switch to [pandas](https://pandas.pydata.org/) if your workflow requires it. ```python theme={null} # Option 1: Set via constructor client = Ionworks(dataframe_backend="pandas") # Option 2: Set via environment variable # IONWORKS_DATAFRAME_BACKEND=pandas # Option 3: Set at runtime from ionworks import set_dataframe_backend, get_dataframe_backend set_dataframe_backend("pandas") print(get_dataframe_backend()) # "pandas" ``` All methods that return DataFrames (time series, steps, cycles) respect this setting. ## Timeout and retry behavior The client automatically retries failed requests on connection errors, timeouts, and server errors (5xx). By default: * Requests time out after **10 seconds** * Failed requests retry up to **5 times** with exponential backoff * **Dropped connections** (the server closed the socket before the request reached the application — common on reused keep-alive connections) are retried for **all methods**, including POST and PATCH. The request never reached the server, so resending is safe. * **Read timeouts** and **5xx responses** are only retried for idempotent methods (**GET** and **DELETE**). The server may have already processed a POST or PATCH, so resubmitting could duplicate the operation. You can customize these settings: ```python theme={null} # Longer timeout for large uploads client = Ionworks(timeout=60) # Disable retries client = Ionworks(max_retries=0) ``` ## Sub-clients The `Ionworks` client exposes domain-specific sub-clients: | Sub-client | Access | Documentation | | -------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | | Projects | `client.project` | [Projects API](/core-concepts/api) | | Models | `client.model` | [Build API](/build/api) | | Parameterized models | `client.parameterized_model` | [Build API](/build/api) | | Studies | `client.study` | [Simulate API](/simulate/api) | | Protocols | `client.protocol` | [Protocol API](/simulate/protocol-api) | | Simulations | `client.simulation` | [Simulate API](/simulate/api) | | Pipelines | `client.pipeline` | [Pipelines API](/build/parameterize/api) | | Simple pipelines | `client.simple_pipeline` | [Pipelines API](/build/parameterize/api#simplepipeline) | | Optimizations | `client.optimization` | [Optimize API](/optimize/api) | | Cell specifications | `client.cell_spec` | [Uploading data](/data/uploading) | | Cell instances | `client.cell_instance` | [Uploading data](/data/uploading) | | Cell measurements | `client.cell_measurement` | [Measurements](/data/measurements) | | Sites | `client.site` | [Lab view](/operate/lab-view) | | Cyclers | `client.cycler` | [Lab view](/operate/lab-view) | | Channels | `client.channel` | [Lab view](/operate/lab-view) | | Lab occupancy | `client.lab` | [Lab view](/operate/lab-view#querying-occupancy-from-the-sdk) | | Planned measurements | `client.planned_measurement` | [Planned measurements](/operate/planned-measurements) | | Analyses | `client.analysis` | [Analyses](/data/analyses) | | Materials | `client.material` | [Materials & property datasets](/data/materials#python-client) | | Material property datasets | `client.material_property_dataset` | [Materials & property datasets](/data/materials#python-client) | | Jobs | `client.job` | [Canceling jobs](#canceling-jobs) | | Search | `client.search` | [Searching across your organization](#searching-across-your-organization) | | Web app URLs | `client.urls` | [Web app URL helpers](#web-app-url-helpers) | ## Searching across your organization `client.search.query()` finds entities by name or description across your whole organization in one call, without knowing which sub-client to reach for: ```python theme={null} response = client.search.query("LGM50") for result in response.results: print(result.entity_type, result.id, result.name) ``` Name fields match on case-insensitive substring; descriptions and notes match on a prefix full-text search. Substring matches rank ahead of full-text matches within each entity type. Searchable entity types are `project`, `model`, `cell_specification`, `cell_instance`, `cell_measurement`, `parameterized_model`, `experiment_template`, `optimization_template`, `material`, `study`, `optimization`, and `pipeline`. | Parameter | Default | Description | | -------------- | ------- | -------------------------------------------------------------------------------- | | `q` | — | Query string. Must be at least two characters. | | `limit` | `25` | Results per page (1–100). | | `offset` | `0` | Results to skip, for pagination. | | `per_type` | `5` | Maximum results per entity type (1–20), so one type cannot crowd out the rest. | | `entity_types` | all | Restrict to specific entity types. | | `project_id` | none | Scope project-scoped types (`study`, `optimization`, `pipeline`) to one project. | ```python theme={null} # Only measurements and instances, 10 of each response = client.search.query( "aging", entity_types=["cell_measurement", "cell_instance"], per_type=10, ) ``` Unlike most sub-clients, `search` does **not** fall back to the client's default project — it spans the whole organization unless you pass `project_id`. Results are always limited to what your account can already read. ## Discovering the API `client.capabilities()` and `client.schema(name)` describe the API from the API itself. Use them from a notebook or script to introduce yourself — or a coding agent you're scripting against — to the platform's data hierarchy and to fetch the authoritative JSON Schemas for measurements and UCP protocols. ```python theme={null} caps = client.capabilities() print(caps["domain_context"]["hierarchy"]) # organization -> project -> cell_specification -> cell_instance # -> cell_measurement -> [time_series, steps, cycles, analysis] ... # Standard time-series column names, required fields, sign conventions data_schema = client.schema("data") # Full UCP JSON Schema plus runnable examples protocol_schema = client.schema("protocol") ``` `capabilities()` also returns pointers to the OpenAPI spec (`/openapi.json`) and per-resource JSON Schema endpoints under `caps["schemas"]`, so agents can fetch the exact shape of any create/update payload before calling it. Coding agents driving Ionworks should call `client.capabilities()` and `client.schema(name)` before generating request bodies — the responses reflect the running server, so agents never guess at endpoint shapes or column names that may have drifted. ## Web app URL helpers `client.urls` builds links to resource pages in the Ionworks web app (Ionworks Studio) without requiring you to hand-construct URLs from entity IDs. Use it to surface clickable links from notebooks, scripts, dashboards, or Slack/email reports so collaborators can jump straight to the resource in the web app. Every helper runs entirely locally — no network call — except `client.urls.simulation()`, which fetches the simulation once when `parameterized_model_id` isn't supplied (see below). ```python theme={null} client = Ionworks(project_id="proj_abc") client.urls.study("study_xyz") # https://app.ionworks.com/dashboard/projects/proj_abc/studies/study_xyz client.urls.parameterized_model("pm_123") # https://app.ionworks.com/dashboard/projects/proj_abc/parameterized-models/pm_123 ``` Each helper takes the resource's own ID plus whatever parent IDs the route requires. `project_id` is optional — it falls back to the [default project](#default-project) configured on the client. | Helper | Returns a link to | | ------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `client.urls.project(project_id=None)` | Project landing page (its studies list). | | `client.urls.study(study_id, project_id=None)` | Study detail page. | | `client.urls.model(model_id, project_id=None)` | Model detail page. | | `client.urls.parameterized_model(parameterized_model_id, project_id=None)` | Parameterized model detail page. | | `client.urls.simulation(simulation_id, parameterized_model_id=None, project_id=None)` | Simulation detail page (nested under its parameterized model). | | `client.urls.protocol(protocol_id, project_id=None)` | Protocol detail page. | | `client.urls.pipeline(pipeline_id, project_id=None)` | Pipeline detail page. | | `client.urls.optimization(optimization_id, project_id=None)` | Optimization detail page. | | `client.urls.material(material_id, project_id=None)` | Material detail page. | | `client.urls.cell_specs(project_id=None)` | Cell specifications list page. | | `client.urls.cell_instances(spec_id, project_id=None)` | Cell instances list page nested under a spec. | | `client.urls.measurement(measurement_id, project_id=None)` | Measurement detail page. | ### Simulation URLs A simulation's web-app route is nested under its parameterized model. If you already know the `parameterized_model_id`, pass it in to avoid a network call: ```python theme={null} client.urls.simulation("sim_1", parameterized_model_id="pm_123") ``` Otherwise the helper fetches the simulation to look it up: ```python theme={null} # One GET to client.simulation.get(simulation_id), then builds the URL. client.urls.simulation("sim_1") ``` If the simulation has no `parameterized_model_id` (e.g. a study-only simulation), the helper raises `ValueError` and you must pass the parent explicitly. ## Canceling jobs You can cancel running jobs (simulations, pipelines, and optimizations) using the Python API. Each job has a unique ID that you can use to cancel it. ```python theme={null} # Cancel a job by ID client.job.cancel(job_id="job_abc123") ``` Cancelling a parent job automatically cancels all of its child jobs. For example, cancelling a pipeline cancels all of its running elements. ## Resubmitting a failed pipeline If a pipeline stops because its first failed element could not be submitted (`error_code = SUBMISSION_FAILED`), you can resubmit it without rebuilding the configuration. Completed elements are preserved; execution resumes from the failed element. The Python client does not yet expose a resubmit method, so call the REST endpoint directly as a temporary workaround. Resubmission goes through the generic jobs endpoint, and a pipeline is identified by its job ID — the same ID you pass to `client.pipeline.get(...)`: ```bash theme={null} curl -X POST \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ https://api.ionworks.com/jobs/{pipeline_id}/resubmit ``` Resubmission only applies to `SUBMISSION_FAILED` errors. For execution timeouts or configuration mistakes, create a new pipeline with the corrected configuration. For internal errors, wait briefly and create a new pipeline if the issue persists. See [Handling pipeline failures](/guide/pipelines/overview#handling-pipeline-failures) for details. ## Retrieving job metadata Standard job-detail responses strip fields that can grow large — submitted configs, full result payloads, and serialized PyBaMM models — so listing and polling stay fast even for pipelines or optimizations with heavy inline data. When you actually need those fields, fetch them with `client.job.get_metadata`: ```python theme={null} # Fetch the full, untruncated metadata for a job metadata = client.job.get_metadata(job_id="job_abc123") # Validation payloads, when present plot_config = metadata.get("validation_plot_config") series_index = metadata.get("validation_series_index") ``` Use this when you need to: * Re-run a job from its original submitted config without keeping a local copy. * Audit the exact inputs that produced a result. * Read large result blobs — for example, optimization traces, or the `validation_plot_config` produced by pipeline validations — that aren't included in the default job response. A validation element's time series are **not** on this blob. They are stored per objective, and `validation_series_index` is the list of what exists. The normal way to read them is off the typed result object — `client.pipeline.result(pipeline_id).element("validate").series` returns `{objective: {source: {channel: [values]}}}` at full resolution — `source` being `"optimal"` or `"baseline"` — and `.overlay` returns the decimated data behind the plots. Drop to `client.pipeline.get_element_series_channels` and `client.pipeline.get_element_series` when you want to fetch one named objective instead of every sample of every objective. The returned object is a plain `dict` parsed from the job's `metadata.json.gz` blob. If a job has no metadata file (for example, it failed before writing one) the call raises an error — wrap it in a `try`/`except` if you're iterating over many jobs. For pipelines, `client.pipeline.result(pipeline_id)` is still the easiest way to get parsed, fitted parameter values. Use `client.job.get_metadata` when you need the raw, unparsed payload backing a job. ## Retrieving MCMC posterior samples Data-fit jobs that use a sampler (for example, `PintsSampler`) evaluate many parameter vectors rather than converging on a single point estimate. The resulting chain is too large for the standard job `result` column, so it is offloaded to the job's metadata blob. For a fit you have the pipeline result for, the typed `PosteriorResult` is the shorter path — `result.element("").chains` gives the `(chain, draw, parameter)` samples and `.marginal("")` gives one parameter's post-burn-in distribution, rebuilding both from the offloaded payload when needed. Reach for the raw dict below when you have only a `job_id`, or want the chain without the result object. The raw chain is retrieved with `client.job.get_posterior_samples`: ```python theme={null} import numpy as np # Fetch the sample chain for a sampler-based datafit samples = client.job.get_posterior_samples(job_id="job_abc123") chain = samples["samples"] # dict[str, list] keyed by parameter name costs = samples["sample_costs"] # cost per sample param_names = samples["sample_param_names"] # parameter names in column order burnin = samples["sample_burnin"] or 0 # burn-in length (may be None) # The chain is 2-D (start, iteration) for a multistart fit and 1-D # (iteration,) for a single start, so drop burn-in off the last axis. post_burnin = {k: np.asarray(v)[..., burnin:] for k, v in chain.items()} ``` The returned dict has four keys: * `samples` — the chain as a `dict[str, list]`, keyed by parameter name. Each value is a nested list of shape `(starts, iterations)` for a multistart fit, and a flat list of length `iterations` when the fit ran a single start. Index the iteration axis last (`[..., burnin:]`) so both shapes work. * `sample_costs` — the objective value at each sample, in the same shape as one parameter's chain. * `sample_param_names` — the parameter names in column order. * `sample_burnin` — the number of initial iterations the sampler treats as burn-in. The chain includes them; discard them before downstream analysis. This is `None` for samplers that have no burn-in concept (`GridSearch`, `PointEstimateSampler`), so guard against it (e.g. `burnin = samples["sample_burnin"] or 0`) before using it as a slice boundary. Every sampler-based fit populates these fields — not just Bayesian ones. `GridSearch` and `PointEstimateSampler` runs also return a populated chain, with `sample_burnin` set to `None`. Fits driven by a conventional optimizer (`CMAES`, `ScipyMinimize`, and friends), along with optimization and validation jobs, return an empty dict. An empty dict also comes back when there is no metadata blob to read — a job that failed before writing one, an unknown `job_id`, or a job belonging to another organization all surface as "no samples" rather than an error. Check that the job exists and completed before reading an empty result as "this fit produced no chain". Use `client.job.get_posterior_samples` for the sample chain alone. For everything else in the metadata blob — submitted configs, validation payloads, optimization traces — use `client.job.get_metadata`. # Python API Source: https://docs.ionworks.com/build/api Create, list, and manage models and parameterized models programmatically with the ionworks-api Python client The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package provides sub-clients for managing [models](/build/models) and [parameterized models](/build/parameterized-models) programmatically. For installation and authentication, see the [Python API client](/api-client) page. ## Models Use `client.model` to create, list, update, and delete models. ### Listing models ```python theme={null} from ionworks import Ionworks client = Ionworks() # List all models models = client.model.list() for model in models.items: print(f"{model.name} ({model.id})") # Filter by name (case-insensitive substring match) models = client.model.list(name="SPM") # Sort by name ascending models = client.model.list(order_by="name", order="asc") # Paginate results models = client.model.list(limit=10, offset=0) print(f"Showing {models.count} of {models.total} models") ``` `list` returns a paginated response with `items`, `count`, and `total`. Supported filters: `name`, `name_exact`, `created_by_email`, `created_after`, `created_before`, `updated_after`, `updated_before`, `order_by`, `order`. Filters and sort parameters are applied server-side, so you can drill into large model libraries without pulling every page. ### Getting a model ```python theme={null} model = client.model.get("your-model-id") print(model.config) # {"type": "SPMe"} ``` The `config` field (e.g. `{"type": "SPMe"}`) is included on `get` responses. It may be `None` on the `create` response — re-fetch with `get` if you need it. ### Creating a model ```python theme={null} model = client.model.create({ "name": "Custom SPM", "config": {"type": "SPM"}, "description": "Single Particle Model with custom variables", }) ``` The `type` in `config` is the PyBaMM model class name (`SPM`, `SPMe`, `DFN`). You can also attach persistent `simulation_settings` (mesh + solver) that will be re-applied whenever the model is simulated — see [Simulation settings](/build/simulation-settings) for the full field list and precedence rules: ```python theme={null} model = client.model.create({ "name": "DFN with refined particle mesh", "config": {"type": "DFN"}, "simulation_settings": { "var_pts": {"r_n": 20, "r_p": 20}, }, }) ``` You only need to create a model when you want custom variables. For a standard model, reference a built-in system model by name (e.g. `"SPMe (Full Cell)"`) when building a parameterized model — no model creation required. See [Models](/build/models). ### Updating a model ```python theme={null} model = client.model.update("your-model-id", { "name": "Custom SPM v2", "description": "Updated description", }) ``` ### Adding a custom variable ```python theme={null} model = client.model.add_custom_variable("your-model-id", { "name": "Total energy [W.h]", "expression": "Voltage [V] * Current [A] * Time [s] / 3600", }) ``` ### Deleting a model ```python theme={null} client.model.delete("your-model-id") ``` ### Downloading an Ionworks model as a PyBaMM model Use `client.model.download()` to fetch an [Ionworks model](/build/models#ionworks-models) (`ECM`, `LumpedSPMR`, `LumpedSPMeR`, the MSMR models, `GITTModel`, ...) as a ready-to-use PyBaMM model. The server builds the model and returns its serialized form, so you can run it locally with only `pybamm` installed — no additional license required. ```python theme={null} import pybamm model = client.model.download("ECM") # model is a pybamm.BaseModel — pass it straight to pybamm.Simulation sim = pybamm.Simulation(model) ``` Pass model constructor options through `options=`, and optionally write the serialized JSON to disk with `path=` so you can reload it later or re-upload it as a [custom model](/build/models#creating-a-model): ```python theme={null} model = client.model.download( "LumpedSPMR", options={"thermal": "lumped", "surface temperature": "ambient"}, path="lumped_spmr.json", ) ``` The available model names match the entries listed under `"ionworks_models"` by `client.pybamm_models()`. Standard PyBaMM models (`SPM`, `SPMe`, `DFN`, ...) are not served by this endpoint — instantiate them directly with `pybamm` instead. 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 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 strict parsers (`JSON.parse`, `jq`, ...) will reject the file. If you only need the raw serialized document — for example, to save it, re-upload it, or inspect it — use `client.model.serialize()`, which returns the dict without loading it through PyBaMM: ```python theme={null} model_json = client.model.serialize("GITTModel") ``` #### Geometry and mesh are preserved Downloaded models carry the serialized `geometry`, `var_pts`, `spatial_methods`, and `submesh_types` that the original Ionworks model was built with. When you re-upload one as a custom model — or feed it into a pipeline that goes through `parse_model` — those values are restored as the model's `default_geometry`, `default_var_pts`, `default_spatial_methods`, and `default_submesh_types`, so a downstream `pybamm.Simulation` discretises against the correct mesh instead of falling back to empty defaults. You do not need to reconstruct the geometry by hand. ## Parameterized models Use `client.parameterized_model` to create, list, and update parameterized models. Parameterized models are scoped to a [cell specification](/core-concepts/cells). ### Listing parameterized models You can list parameterized models scoped to a single cell specification or across every cell specification in a project. ```python theme={null} # List parameterized models for a single cell specification param_models = client.parameterized_model.list_by_cell_specification("your-cell-spec-id") for pm in param_models: print(f"{pm.name} (ID: {pm.id})") # Paginate results param_models = client.parameterized_model.list_by_cell_specification( "your-cell-spec-id", limit=10, offset=0 ) ``` To list every parameterized model linked to any cell specification in a project, use `list_by_project`. When `project_id` is omitted it defaults to the `project_id` configured on the client (see [API client](/api-client)). ```python theme={null} # All parameterized models in the client's default project param_models = client.parameterized_model.list_by_project() # Explicit project, paginated param_models = client.parameterized_model.list_by_project( project_id="your-project-id", limit=100, offset=0 ) # Narrow a project-scoped query to one cell specification param_models = client.parameterized_model.list_by_project( project_id="your-project-id", cell_spec_id="your-cell-spec-id" ) ``` `list_by_project` accepts `limit` values up to 1000, so you can load every model for a project in a single request when populating UI selectors or bulk-processing models. ### Getting a parameterized model ```python theme={null} param_model = client.parameterized_model.get("your-parameterized-model-id") ``` ### Creating a parameterized model ```python theme={null} param_model = client.parameterized_model.create("your-cell-spec-id", { "name": "NMC622 Fitted Parameters", "model_id": "your-model-id", "description": "Parameters from 1C discharge fitting", "parameters": { "Negative electrode diffusivity [m2.s-1]": 3.3e-14, }, }) ``` Pass `simulation_settings` to pin parameter-specific mesh or solver requirements. Parameter-level settings take precedence over the base model's, so this is the usual place to record the grid a fit was performed on — see [Simulation settings](/build/simulation-settings): ```python theme={null} param_model = client.parameterized_model.create("your-cell-spec-id", { "name": "NMC622 Fitted Parameters", "model_id": "your-model-id", "parameters": { "Negative electrode diffusivity [m2.s-1]": 3.3e-14, }, "simulation_settings": { "var_pts": {"r_n": 32, "r_p": 32}, }, }) ``` ### Creating or getting a parameterized model Use `create_or_get` to make setup scripts safely re-runnable. If a parameterized model with the same name already exists for the cell specification, the client returns the existing one instead of raising a `409 Conflict` error. ```python theme={null} param_model = client.parameterized_model.create_or_get("your-cell-spec-id", { "name": "NMC622 Fitted Parameters", "model_id": "your-model-id", "parameters": { "Negative electrode diffusivity [m2.s-1]": 3.3e-14, }, }) ``` This mirrors the `create_or_get` behaviour already available on `client.cell_spec`, `client.cell_instance`, and `client.cell_measurement` — see [idempotent uploads](/data/uploading#idempotent-uploads-with-create_or_get) for the same pattern applied to cell data. ### Updating a parameterized model ```python theme={null} param_model = client.parameterized_model.update( "your-cell-spec-id", "your-parameterized-model-id", {"name": "NMC622 Fitted Parameters v2"}, ) ``` ### Getting parameter values Retrieve all parameter values as a dictionary, useful as baseline parameters for data fitting or optimization workflows. ```python theme={null} params = client.parameterized_model.get_parameter_values("your-parameterized-model-id") print(params) # {"Negative electrode diffusivity [m2.s-1]": 3.3e-14, ...} ``` ### Getting variable names List the scalar variable names available from a parameterized model. ```python theme={null} variables = client.parameterized_model.get_variable_names("your-parameterized-model-id") print(variables) # ["Terminal voltage [V]", "Current [A]", ...] ``` ### Persisting simulation settings A parameterized model can carry its own **simulation settings** — the mesh (`var_pts`, `submesh_types`), spatial discretisation (`spatial_methods`), and solver configuration to use whenever this model is simulated. Persisting settings ensures the model simulates the same way everywhere it's used — a DataFit, a validation run, a downstream sweep — without you having to reconfigure the mesh and solver in each caller. Build a `SimulationSettings` from live PyBaMM objects with `iws.models.SimulationSettings(...)`, then pass it under the `simulation_settings` key when creating or updating a parameterized model: ```python theme={null} import pybamm import ionworks_schema as iws model = pybamm.lithium_ion.DFN() settings = iws.models.SimulationSettings( var_pts={"x_n": 20, "x_s": 10, "x_p": 20, "r_n": 20, "r_p": 20}, submesh_types=model.default_submesh_types, spatial_methods=model.default_spatial_methods, solver=pybamm.IDAKLUSolver(rtol=1e-6, atol=1e-6), ) param_model = client.parameterized_model.create("your-cell-spec-id", { "name": "NMC622 — fine mesh", "model_id": "your-model-id", "parameters": {...}, "simulation_settings": settings, }) ``` The stored settings come back on the parameterized model's `simulation_settings` field. Omit `simulation_settings` on create and the model uses the built-in defaults for its underlying model type. Two write-time limits are worth knowing before you build a settings block: * **`geometry` is rejected.** Persisting a geometry override returns `400 BAD_REQUEST`; persist `var_pts` / `submesh_types` / `spatial_methods` / `solver` instead and let the geometry come from the model. * **Solvers are allowlisted.** Only `IDAKLUSolver`, `AlgebraicSolver`, and `NonlinearSolver` may be stored. The deprecated `CasadiSolver` and `ScipySolver` are rejected. `var_pts` keys, submesh classes, and spatial-method classes are validated against allowlists too, so an unrecognized value fails the write rather than the later simulation. You can find the ID for any resource from the Ionworks Studio web app. The ID is displayed in the URL when you navigate to a resource's detail page. ## ECM parameterization Use `client.ecm` to fit an Equivalent Circuit Model (R0 + N RC pairs, plus optional OCV) to cycling data and persist the result as a [Parameterized Model](/build/parameterized-models). Authenticated fits run as background jobs — `fit_from_measurements` and `fit_from_file` return an `EcmFitJob` handle immediately, and `wait_for_completion` blocks until the worker finishes (typically 10–60 s). See [ECM parameterization](/build/parameterize/ecm#fitting-from-python) for the full guide, including `ocv_soc_curve` co-capacity fits, per-segment SOC seeds, and knot-resolution tuning. ### Fitting from stored measurements ```python theme={null} from ionworks import Ionworks client = Ionworks() fit_job = client.ecm.fit_from_measurements({ "measurements": [ {"id": "meas-id-1"}, {"id": "meas-id-2", "start_step": 5, "end_step": 50, "initial_soc": 0.95}, ], "ecm_options": {"num_rcs": 2, "fit_ocv": True}, }) result = client.ecm.wait_for_completion(fit_job, timeout=300) print(f"RMSE: {result.rmse_mV:.2f} mV") ``` ### Fitting from a local file ```python theme={null} fit_job = client.ecm.fit_from_file("data/pulse_test.csv", num_rcs=2, capacity=4.85) result = client.ecm.wait_for_completion(fit_job) ``` `fit_from_file` accepts CSV, parquet, and any cycler format that `ionworksdata` can detect. Use `client.ecm.detect_and_read(file)` to preview a file before fitting. ### Saving a fit as a Parameterized Model ```python theme={null} saved = client.ecm.save_to_project( name="ECM 2RC — pulse test", cell_spec_id="cell-spec-id", fit_results=result, ) print(saved.parameterized_model_id) ``` The returned `parameterized_model_id` can be used as `parameterized_model` in `client.simulation.protocol(...)`. ### Fitting a built-in example (no auth) ```python theme={null} examples = client.ecm.list_examples() result = client.ecm.fit_from_example(examples[0]["id"], num_rcs=2) ``` The demo endpoint is rate-limited (60/min) and synchronous. RC-pair parameters are only included for authenticated callers whose organization has ECM results access enabled. # Custom Variables Source: https://docs.ionworks.com/build/custom-variables Define custom output quantities like temperature in Celsius or electrode potentials using PyBaMM expressions Custom variables let you define new quantities that are computed from the model's built-in state variables and parameters. They are evaluated lazily when you request them in simulation results, so they don't slow down the simulation itself. Custom variables are stored on the model and are available to all simulations and optimizations that use that model. ## Variables vs custom variables Battery models solve for dozens of built-in **variables** — quantities like voltage, current, and temperature that the solver computes at each time step. **Custom variables** are expressions you define that combine these built-in variables with parameters and math. | | Variable | Custom variable | | ------------------ | ----------------------------------------------- | -------------------------------------------------- | | **Source** | Solved by the PyBaMM model | Defined by you | | **Examples** | `Voltage [V]`, `Current [A]`, `Temperature [K]` | `Temperature [degC]`, `Anode potential [V]` | | **Referenced via** | `CoupledVariable("name")` | Also `CoupledVariable("name")` once defined | | **Editable** | No | Append-only — cannot edit or delete after creation | Custom variables are append-only because their evaluated values are stored in simulation result files. Changing or removing a variable after simulations have run would invalidate those results. ## Pre-configured custom variables All system models (SPM, SPMe, DFN, their composite variants, LumpedSPMR, LumpedSPMeR, and both full-cell and half-cell ECM) come with the following custom variables already defined: | Variable | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------ | | `Anode potential [V]` | Negative electrode potential, derived from the appropriate internal model variable for each model type | | `Cathode potential [V]` | Positive electrode potential, derived from the appropriate internal model variable for each model type | | `Temperature [degC]` | Cell temperature converted from Kelvin to Celsius | These are available in simulation results immediately — you don't need to add them yourself when using system models. If you create a new custom model by cloning a system model, the custom variables carry over automatically. If you create a model from scratch, you need to add any custom variables you want manually. ### Electrode potential expressions by model type The expression used for electrode potentials depends on the model type: | Model type | Anode potential expression | Cathode potential expression | | -------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Full-cell (SPM, SPMe, DFN) | `CoupledVariable("Negative electrode surface potential difference at separator interface [V]")` | `CoupledVariable("Positive electrode surface potential difference at separator interface [V]")` | | Half-cell (SPM, SPMe, DFN) | `Scalar(0)` | `CoupledVariable("Voltage [V]")` | | Full-cell ECM | `CoupledVariable("Anode potential [V]")` | `CoupledVariable("Cathode potential [V]")` | | Half-cell ECM | `Scalar(0)` | `CoupledVariable("Voltage [V]")` | | LumpedSPMR, LumpedSPMeR | `CoupledVariable("X-averaged negative electrode surface potential difference [V]")` | `CoupledVariable("X-averaged positive electrode surface potential difference [V]")` | The full-cell ECM is circuit-based and does not solve for individual electrode potentials directly. Instead, it reconstructs them by splitting the total overpotential between the anode and cathode using the **Anode overpotential fraction** parameter. See [ECM in Models](/build/models#ionworks-models) for details and use cases such as reproducing BioLogic EWE/ECE control. The half-cell ECM treats the working electrode as the cathode (anode potential = 0 V), so the cell voltage equals the cathode potential. ## Expression Syntax Custom variable expressions use PyBaMM building blocks: ### Types | Type | Purpose | Example | | ------------------------- | ----------------------------------------------------- | -------------------------------- | | `CoupledVariable("name")` | Reference a model variable or another custom variable | `CoupledVariable("Voltage [V]")` | | `Parameter("name")` | Reference or create a model parameter | `Parameter("C-rate")` | | Numbers | Constant values — use plain numbers in expressions | `273.15`, `3600` | ### Math Operations Standard arithmetic: `+`, `-`, `*`, `/`, `**` (power) ### Functions `exp()`, `log()`, `sqrt()`, `tanh()`, `cosh()`, `sinh()` ## Examples **Temperature conversion (Kelvin to Celsius):** ```python theme={null} CoupledVariable("Volume-averaged cell temperature [K]") - 273.15 ``` **Electrode potential from internal model variable:** ```python theme={null} CoupledVariable("Negative electrode surface potential difference at separator interface [V]") ``` **Scaled parameter for use in expressions:** ```python theme={null} Parameter("C-rate") * 3600 ``` **Power output (combining two model variables):** ```python theme={null} CoupledVariable("Voltage [V]") * CoupledVariable("Current [A]") ``` **Using math functions:** ```python theme={null} exp(CoupledVariable("Overpotential [V]") / 0.026) ``` ## Evaluation Custom variables are evaluated lazily — only when you view them in simulation results. When you select a custom variable for plotting or display, the system submits an evaluation job in the background. A loading indicator shows progress while the evaluation runs. If an evaluation fails (for example, due to a transient error), the plot displays an error state instead of continuing to load. Click the **Retry** button that appears to resubmit the evaluation job. ## Validation When you add a custom variable, the system validates your expression: 1. **Valid syntax** — the expression must parse as a valid PyBaMM expression 2. **References exist** — all `CoupledVariable` references must point to existing model variables or other custom variables you've already defined 3. **No circular references** — custom variable A cannot reference B if B references A (directly or indirectly) 4. **Parameter awareness** — if you use `Parameter("name")`, the system tells you whether it matches an existing model parameter or creates a new one. If the name doesn't match exactly, it suggests similar existing parameters ## Limitations * **Append-only** — custom variables cannot be edited or deleted after creation * **Not available on uploaded models** — only built-in PyBaMM model types support custom variables * **Cannot modify system models** — clone a system model first, then add custom variables to the clone # Parameter Interpolants Source: https://docs.ionworks.com/build/interpolants Define model parameters as lookup tables using CSV data with linear, cubic, or pchip interpolation ## What is an Interpolant? An **interpolant** is a parameter defined as a lookup table rather than a single value or mathematical expression. Interpolants are ideal for representing experimental data or complex relationships that don't have simple mathematical formulas. When you define a parameter as an interpolant, you provide discrete data points, and Ionworks Studio automatically interpolates between them during simulations. ## When to Use Interpolants Interpolants are particularly useful for: * **Open-circuit potential (OCP) curves**: Voltage as a function of stoichiometry from experimental measurements * **Diffusivity data**: Diffusion coefficients that vary non-linearly with concentration or temperature * **Temperature-dependent properties**: Physical properties measured at discrete temperature points * **Complex multi-variable relationships**: Parameters that depend on multiple inputs (e.g., conductivity as a function of both concentration and temperature) Use interpolants when you have experimental data that's difficult to express as a mathematical function, or when a lookup table provides better accuracy than a fitted equation. ## Creating an Interpolant ### Step 1: Identify Compatible Parameters Not all parameters can be converted to interpolants. Only parameters that accept function inputs can become interpolants. To check if a parameter can be an interpolant: 1. Navigate to the **Model Editor** when creating or cloning a model 2. Look for the parameter you want to modify 3. Click the **change circle icon** (⟳) next to the parameter value 4. If "Interpolant" appears in the menu, the parameter supports interpolants Parameters that only accept scalar values (like geometric dimensions) cannot be converted to interpolants. ### Step 2: Convert the Parameter Type To convert a parameter to an interpolant: 1. Click the **change circle icon** (⟳) next to the parameter value 2. Select **Interpolant** from the dropdown menu 3. The interpolant editor will open ### Step 3: Enter Data You have two options for entering interpolant data: #### Option A: Upload a CSV File The quickest way to define an interpolant is to upload a CSV file: 1. In the interpolant editor, click the **Upload File** button or drag and drop a CSV file 2. Format your CSV file with columns for: * First column(s): Input variable(s) (e.g., stoichiometry, temperature) * Last column: Output value (the parameter value) **Example CSV for 1D interpolant (e.g., OCP vs. Stoichiometry):** ```csv theme={null} 0.0, 3.5 0.1, 3.6 0.2, 3.65 0.3, 3.7 0.5, 3.8 0.7, 3.85 0.9, 3.95 1.0, 4.1 ``` **Example CSV for 2D interpolant (e.g., Diffusivity vs. Stoichiometry and Temperature):** ```csv theme={null} 0.0, 298, 1.5e-14 0.0, 323, 2.8e-14 0.5, 298, 1.8e-14 0.5, 323, 3.2e-14 1.0, 298, 1.6e-14 1.0, 323, 3.0e-14 ``` #### Option B: Manual Entry For smaller datasets, you can enter data points manually: 1. Click **Insert Row Above** or **Insert Row Below** to add new data points 2. Click on any cell in the table to edit the value 3. Enter your data points 4. Use **Delete Row** to remove unwanted points For 1D interpolants, you can edit both input (x) and output (y) values. For multi-dimensional interpolants, only output (y) values are editable after uploading data—input combinations must be defined via CSV. ### Step 4: Choose Interpolation Method Select the interpolation method that best fits your data: * **Linear**: Straight lines between data points. Best for coarse data or when you want to avoid overfitting. * **Cubic**: Smooth curves using cubic splines. Ideal for smooth physical relationships, but can overshoot near sharp features and produce unphysical values (for example, negative diffusivities). * **Pchip**: Piecewise cubic Hermite interpolating polynomial. Produces a smooth curve like cubic, but guarantees the interpolant stays between adjacent data points—so monotonic data stays monotonic and values never overshoot the bracketing samples. Recommended for diffusivity tables and OCP data that varies sharply with stoichiometry. For most electrochemical properties like OCP curves, **cubic** or **pchip** interpolation provides the smoothest, most physically realistic results. Prefer **pchip** when the underlying data is (or should be) monotonic, or when unconstrained cubic interpolation produces unphysical overshoot. ### Step 5: Preview and Save * For **1D interpolants**, a plot preview appears on the right side showing how your data will be interpolated * Review the plot to ensure the interpolation looks physically reasonable * Click **Save** to apply the interpolant to your parameter ## Working with Multi-Dimensional Interpolants Multi-dimensional interpolants allow you to define parameters that depend on multiple variables simultaneously (e.g., conductivity as a function of both concentration and temperature). ### Requirements * The parameter must accept a function with multiple inputs * Data must be provided as a CSV file with all input combinations you want to define * The CSV format is: `input1, input2, ..., output` ### Example: Diffusivity(Stoichiometry, Temperature) If a parameter accepts a function like `D(sto, T)`, you can create a 2D interpolant: ```csv theme={null} stoichiometry, temperature, diffusivity 0.0, 298, 1.5e-14 0.0, 323, 2.8e-14 0.2, 298, 1.7e-14 0.2, 323, 3.0e-14 0.5, 298, 1.8e-14 0.5, 323, 3.2e-14 0.8, 298, 1.7e-14 0.8, 323, 3.1e-14 1.0, 298, 1.6e-14 1.0, 323, 3.0e-14 ``` During simulation, Ionworks Studio will interpolate between these points to evaluate the parameter at any (stoichiometry, temperature) combination. Multi-dimensional interpolants require careful data preparation. Ensure your input space is adequately sampled to avoid extrapolation errors during simulation. ## Best Practices ### Data Quality * **Use sufficient data points**: For 1D interpolants, 10-20 points often provide good accuracy * **Cover the full range**: Ensure your data spans the entire range the parameter will encounter during simulation * **Avoid extrapolation**: Simulations that require parameter values outside your data range will extrapolate, which can lead to unphysical results * **Check for noise**: Smooth or filter noisy experimental data before creating an interpolant ### Testing Your Interpolant After creating an interpolant: 1. **Visual inspection**: For 1D interpolants, check the plot preview to ensure the curve looks physically reasonable 2. **Run a test simulation**: Verify that your model with the interpolant produces expected results 3. **Compare with original data**: If converting from a function to an interpolant, compare simulation results to ensure consistency ### Converting Between Parameter Types You can always switch a parameter between value, function, and interpolant: 1. Click the **change circle icon** (⟳) 2. Select the new type 3. Ionworks Studio temporarily saves your previous parameter definition, so switching back preserves your work within the same editing session When iterating on a model, you might start with a simple function, then refine to an interpolant as you gather experimental data. Cloning models makes this workflow seamless. ## Common Use Cases ### Open-Circuit Potential (OCP) The most common use of interpolants is for OCP curves: ```csv theme={null} stoichiometry, voltage 0.01, 0.15 0.10, 0.35 0.20, 0.45 0.50, 0.60 0.80, 0.75 0.90, 0.85 0.99, 0.95 ``` Parameter name: `Positive electrode OCP [V]` or `Negative electrode OCP [V]` ### Temperature-Dependent Conductivity ```csv theme={null} temperature, conductivity 273, 0.5 298, 1.0 323, 1.8 348, 2.9 ``` Parameter name: `Electrolyte conductivity [S.m-1]` ### Concentration-Dependent Diffusivity ```csv theme={null} concentration, diffusivity 1000, 1.2e-14 5000, 1.5e-14 10000, 1.8e-14 15000, 1.6e-14 20000, 1.4e-14 ``` Parameter name: `Positive electrode diffusivity [m2.s-1]` or `Negative electrode diffusivity [m2.s-1]` ## Technical Details ### Interpolation Algorithms * **Linear**: Uses `scipy.interpolate.interp1d` with `kind='linear'` * **Cubic**: Uses `scipy.interpolate.interp1d` with `kind='cubic'` * **Pchip**: Uses `scipy.interpolate.PchipInterpolator` (monotonic cubic interpolation) During simulation, PyBaMM's `Interpolant` class handles the evaluation of interpolated values. ## Troubleshooting ### "Missing values in the table" This error occurs when your data contains NaN or missing values. Check your CSV file for: * Empty cells * Non-numeric data * Formatting issues ### Simulation fails with interpolant If your simulation fails after adding an interpolant: 1. **Check data range**: Ensure your interpolant covers the full range of values encountered during simulation 2. **Try different interpolation method**: Switch from cubic to linear or pchip 3. **Increase data points**: Add more points in regions where the parameter changes rapidly 4. **Verify units**: Confirm that your data uses the correct units for both inputs and outputs ### Cannot convert parameter to interpolant If the interpolant option doesn't appear: * The parameter only accepts scalar values and cannot be an interpolant * Try using a function parameter instead, or check the parameter library documentation for that specific parameter ## Related Topics * [Models](/build/models): Learn more about model creation and parameter configuration * [Simulations](/simulate/simulations): Understand how interpolants are used during simulation # Models Source: https://docs.ionworks.com/build/models Create reusable PyBaMM model configurations (SPM, SPMe, DFN) with options and version pinning for battery simulations A **Model** in Ionworks is a mathematical model configuration that defines the structure and equations used to simulate battery behavior. Models specify: * **PyBaMM Model Type**: The underlying mathematical model (e.g., `SPM`, `SPMe`, `DFN`) * **Model Options**: Configuration options that modify the model's behavior * **PyBaMM Version**: Automatically set to the current PyBaMM version when the model is created (not user-specifiable) * **Cell Compatibility**: Whether the model works with full cells, half cells, or both Models are reusable—multiple [Parameterized Models](/build/parameterized-models) can reference the same Model configuration. This allows you to create different parameter sets for the same mathematical model without duplicating the model configuration. **Models are Immutable** Once a model is created and used in a parameterized model or simulation, its configuration (PyBaMM model type, options, version) cannot be changed. This ensures reproducibility—parameterized models and simulations are permanently linked to the exact model version that was used. To modify a model's configuration, you must create a new model. Only the name and description can be updated after creation. ### Creating a Model Models can be created in two ways: 1. **Use System Models**: Ionworks provides pre-created system models for common PyBaMM model types (SPM, SPMe, DFN) in both full-cell and half-cell configurations. These are available to all users and are the recommended starting point for most use cases. 2. **Create Custom Models**: You can create custom models with specific configurations and options. This is useful when you need to customize model options. **System Models**: Ionworks automatically includes system models when you list models in your organization. These include: - **Full Cell**: SPM, SPMe, DFN, LumpedSPMR, LumpedSPMeR, ECM, plus **SPM Composite**, **SPMe Composite**, and **DFN Composite** for blended (two-phase) negative electrodes - **Half Cell**: SPM, SPMe, DFN, ECM. You can use these system models directly without creating your own. ### Available Model Types Ionworks supports several electrochemical models, each offering different levels of detail and computational complexity: #### PyBaMM Standard Models **SPM (Single Particle Model)** : The simplest model that represents each electrode as a single, average-sized spherical particle. It focuses on solid-phase diffusion within particles and assumes uniform electrolyte concentration and potential, neglecting electrolyte dynamics. This model is computationally efficient and suitable for applications where electrolyte variations are minimal. **SPMe (Single Particle Model with Electrolyte)** : Builds upon the SPM by incorporating electrolyte dynamics, accounting for variations in electrolyte concentration and potential across the cell. This enhancement provides more accurate representation of battery behavior, especially under conditions where electrolyte effects are significant. The SPMe balances computational efficiency with improved accuracy. **DFN (Doyle-Fuller-Newman Model)** : The most comprehensive model that considers multiple spherical particles within each electrode, solid-phase diffusion, electrolyte dynamics, and Butler-Volmer kinetics. This detailed approach captures intricate interactions within the battery but requires more computational resources. Best for applications requiring high accuracy and detailed understanding of internal processes. **SPM Composite, SPMe Composite, DFN Composite (Full Cell)** : Variants of SPM, SPMe, and DFN configured for **composite (blended) negative electrodes** with two particle phases — for example, graphite + silicon anodes. Each model is created with the PyBaMM options: ```json theme={null} { "particle phases": ["2", "1"], "open-circuit potential": [["single", "current sigmoid"], "single"], "particle mechanics": "swelling only" } ``` Use these system models when your cell has a blended anode that requires a current-sigmoid OCP for the secondary phase and swelling mechanics. They share the same custom variables (`Temperature [degC]`, `Anode potential [V]`, `Cathode potential [V]`) as the standard full-cell models, so existing parameterized models, optimizations, and validations work without changes. #### Ionworks Models **LumpedSPMR (Lumped Single Particle Model with Resistance)** : An enhanced single particle model that includes resistance effects and supports thermal modeling. This model represents both electrodes, each as a single spherical particle, and includes lumped resistance terms. It supports thermal options ("isothermal" or "lumped") and surface temperature options ("ambient" or "lumped"). Available for full cell configurations only. **LumpedSPMeR (Lumped Single Particle Model with Electrolyte and Resistance)** : Builds upon LumpedSPMR by incorporating electrolyte dynamics. This model adds electrolyte concentration overpotential using a tanks-in-series approach for volume-averaged electrolyte equations, accounting for concentration variations across negative electrode, separator, and positive electrode regions. It provides improved accuracy for conditions where electrolyte effects are significant while maintaining computational efficiency. Supports the same thermal and surface temperature options as LumpedSPMR. Available for full cell configurations only. **ECM (Equivalent Circuit Model)** : A circuit-based model that represents the battery as an equivalent electrical circuit with an open-circuit voltage (OCV) source and resistance-capacitance (RC) pairs. This model is computationally very efficient and useful for control applications and real-time simulations. Supports configurable RC pairs and thermal modeling options ("isothermal", "lumped", or "two-state"). Available for both full cell and half cell configurations. The ECM also supports an optional **throughput-based degradation model**: set `"capacity"` and/or `"resistance scale"` to `"function"` (both default to `"constant"`) and supply a function of `(Q_throughput, time)` — the running capacity throughput `∫|I| dt` in A·h and elapsed simulation time in seconds — for `Capacity [A.h]` and/or `Resistance scale`, a multiplier applied to `R0` and every `R_rc` — for example, a fixed percentage loss per equivalent cycle for cycle ageing, or a function of elapsed time for calendar ageing. A degradation-enabled solution exposes `Capacity throughput [A.h]` (when either option is `"function"`), `Capacity [A.h]` (when `"capacity"` is `"function"`), and `Resistance scale` (when `"resistance scale"` is `"function"`) as extra output variables — reading one while its option is left at `"constant"` raises a missing-variable error rather than returning a default value. See the [degradation primer](/guide/batteries-101/degradation) for the physics behind capacity fade and resistance growth. The ECM also supports an optional **Butler-Volmer charge-transfer element**: set the `"butler-volmer"` option to `"true"` (it defaults to `"false"`) to add a charge-transfer overpotential term to the series overpotential, driven by an `Exchange current [A]` parameter. Because that term is linear in current at low current but flattens off at high current, it captures rate-dependent overpotential that a single linear `R0` cannot — useful when one resistance cannot fit both gentle and aggressive currents at once. The term is exposed as the `Charge-transfer overpotential [V]` output variable, and the `"resistance scale"` degradation factor above applies only to `R0` and `R_rc`, not to this term. By default the exchange current is a function of state of charge. To change that, use the `"i0"` key of the `"parameter_dependencies"` option — for example `{"i0": ()}` for a constant exchange current, or `{"i0": ("soc", "temperature")}` to add a temperature dependence. Unlike the other `parameter_dependencies` keys, omitting `"i0"` from a supplied mapping still gives the state-of-charge-dependent default, so a parameter set written before this option existed keeps working unchanged. The ECM also exposes reconstructed electrode potentials as outputs (`Anode potential [V]` and `Cathode potential [V]`), which is useful for reproducing working-electrode and counter-electrode control modes — for example, the BioLogic EWE (working-electrode voltage) and ECE (counter-electrode voltage) limits. The total cell overpotential is split between the two electrodes using the **Anode overpotential fraction** parameter (`0` to `1`). Use `0` when the anode is a non-polarisable reference (e.g. a Li metal counter electrode in a half cell), or `1` when the cathode is the non-polarisable reference. Intermediate values are a rough approximation only. To use this output, your parameter set must also define `Anode open-circuit potential [V]` and `Cathode open-circuit potential [V]` as functions of state of charge. Built-in chemistry parameter sets (e.g. `NMC/Graphite`, `LFP/Graphite`, `LFP/Li metal`) populate these automatically; ECM fits from the [ECM Parameterization](/build/parameterize/ecm) tool default to anode = 0 and cathode = OCV so that `Cathode potential − Anode potential = Voltage` by construction. ### Model Options and Capabilities Models can be configured with various options to customize their behavior: * **Thermal modeling**: Some models support "isothermal" (constant temperature), "lumped" (single temperature state), or "two-state" thermal options * **Surface temperature**: LumpedSPMR and LumpedSPMeR support "ambient" or "lumped" surface temperature modeling * **RC pairs**: ECM supports configurable resistance-capacitance (RC) pairs to capture dynamic polarization behavior * **Parameter dependencies**: ECM allows parameters to depend on variables like SOC, current, or temperature * **Working electrode**: PyBaMM standard models (SPM, SPMe, DFN) for half-cell configurations use the "positive" working electrode option ### Editing Models **Models** can have their name and description updated, but the model configuration (PyBaMM model type, options, version) cannot be changed after creation. This immutability ensures reproducibility—any parameterized model or simulation using a model will always reference the exact same model configuration. To modify a model's configuration, you must create a new model. You can define derived quantities like temperature in Celsius or electrode potentials using [Custom Variables](/build/custom-variables). ### Simulation Settings Models can also carry an optional **simulation\_settings** block (mesh + solver kwargs) that Ionworks re-applies every time the model is simulated. Use this when a model configuration needs a specific particle mesh or solver to run correctly. See [Simulation settings](/build/simulation-settings). ### Next Steps Once you have a Model, you can create a [Parameterized Model](/build/parameterized-models) by combining it with specific parameters for your cell chemistry and design. # Python API Source: https://docs.ionworks.com/build/parameterize/api Submit, monitor, list, and retrieve results for pipeline jobs with the ionworks-api Python client. The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package exposes `client.pipeline` for running and managing [pipelines](/build/parameterize/overview). For installation and authentication, see the [Python API client](/api-client) page. ## Submitting a pipeline ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks # Reads IONWORKS_API_KEY and IONWORKS_PROJECT_ID from the environment. client = Ionworks() pipeline = iws.Pipeline( { "known": iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, ), "Q_pos": iws.calculations.ElectrodeCapacity(electrode="positive"), }, name="Capacity pipeline", ) submission = client.pipeline.create(pipeline) print(f"Pipeline ID: {submission.id}") print(f"Status: {submission.status}") ``` `client.pipeline.create()` accepts either an `iws.Pipeline` schema instance or the dict returned by `.to_config()`. Schema instances are validated locally before submission, so shape errors surface immediately. ### Serializing a pipeline to JSON Use `.to_config()` when you want to inspect, cache, or transport the pipeline payload as JSON — for example to review it before submission, commit it to version control, or hand it off to another process. ```python theme={null} import json config = pipeline.to_config() # Inspect or save with open("pipeline_config.json", "w") as f: json.dump(config, f, indent=2) # Submit later, or from another process submission = client.pipeline.create(config) ``` `.to_config()` is the only supported serializer. It emits the discriminators pipeline elements and objectives need (top-level elements are keyed on `element_type`, and nested schemas carry their own `type`), and emits each field under its wire name (for example, `data_input` → `data`). Do **not** use Pydantic's `model_dump()` to build API payloads. `model_dump()` drops these discriminators and emits Python attribute names instead of wire names, so it produces a dict the API may reject. ### Overriding submission metadata `create()` accepts optional `project_id`, `name`, `description`, and `options` kwargs that override any values carried on the schema: ```python theme={null} submission = client.pipeline.create( pipeline, project_id="your-project-id", name="Custom run name", options={"live_progress_updates": True}, ) ``` When `project_id` is omitted, the client falls back to the [default](/api-client#default-project) configured on `Ionworks(...)` or the `IONWORKS_PROJECT_ID` environment variable. ### Data references in pipelines Use these prefixes to reference data sources in pipeline configs: | Prefix | Example | Description | | --------- | --------------------- | --------------------------------------- | | `db:` | `"db:measurement-id"` | Reference an uploaded measurement by ID | | `file:` | `"file:data.csv"` | Load a local CSV file | | `folder:` | `"folder:data_dir/"` | Load from a local directory | For inline DataFrames in pipeline configs, there is a 1,000-row limit. Upload larger datasets as measurements first, then reference them with `db:measurement-id`. A `db:` measurement that is still being recorded grows as rows are appended, so an unpinned config reads more data on a later run than it did on the first. Add a `time_range` alongside `data` to pin the run to a fixed window of elapsed seconds: ```python theme={null} {"data": "db:measurement-id", "time_range": {"start": 0, "end": 3600}} ``` See [pinning a measurement to a time window](/data/reading#pinning-a-measurement-to-a-time-window). The `folder:` scheme expects a directory containing `time_series` and `steps` files. Both `.parquet` and `.csv` are supported, and parquet is preferred when both are present. For example, a folder with `time_series.parquet` and `steps.parquet` (or `.csv`) loads correctly. ## PyBaMM model support You can hand an objective a PyBaMM model object directly instead of naming a built-in model. It is serialized for you when the config is built — there is no registration step and no string name to look up. ```python theme={null} import pybamm import ionworks_schema as iws objective = iws.objectives.CurrentDriven( data_input="path/to/discharge.csv", options={"model": pybamm.lithium_ion.DFN()}, ) ``` Model options travel with the object, so a configured model arrives configured: ```python theme={null} model = pybamm.lithium_ion.DFN(options={"thermal": "lumped"}) objective = iws.objectives.CurrentDriven( data_input="path/to/discharge.csv", options={"model": model}, ) ``` ## Waiting for completion ```python theme={null} submission = client.pipeline.wait_for_completion( submission.id, timeout=600, # seconds (default: 600) poll_interval=2, # seconds between polls (default: 2) verbose=True, # print status updates (default: True) ) ``` Returns once the pipeline reaches a terminal status — `completed`, `failed`, or `canceled`. A cancelled pipeline is terminal, so it returns immediately rather than polling to the timeout. Pass `raise_on_failure=False` to get the terminal submission response back instead of raising when the pipeline fails or is cancelled. ## Retrieving results Each element returns a typed result object. Ask the pipeline for an element by the name you gave it, index the fitted parameters, and plot: ```python theme={null} result = client.pipeline.result(submission.id) fit = result.element("fit") print(fit.parameter_values["Positive particle diffusivity [m2.s-1]"]) fit.plot_fit_results() # measured vs fitted, one figure per objective fit.plot_trace() # optimizer cost + parameter convergence ``` The class you get back reflects what the element did — `OptimizationResult`, `PosteriorResult`, `ValidationResult` and so on, all sharing a `BaseResults` base — so `parameter_values`, `to_config()` and the plots work the same way on any of them. `result.results` returns every element at once, keyed by name. Plotting needs the optional extra: `pip install ionworks-schema[plot]`. The overlay and the optimizer trace are fetched on first access, not when the result is built, so reading `parameter_values` costs nothing extra. The raw payloads are still there if you want them — `result.result` for the pipeline's final parameter values and `result.element_results` for per-element dicts, keyed exactly as you passed them to `iws.Pipeline(elements=...)`. ### Element metadata Elements write large fields (a validation's full per-point comparison, for example) to a metadata blob rather than into `element_results`. The result object already reads what it needs from there; fetch the blob yourself only for fields it does not expose: ```python theme={null} metadata = client.pipeline.get_element_metadata(submission.id, "validate") ``` ### Data-fit parameter trace `fit.plot_trace()` draws the optimizer's per-iteration progress, and `fit.trace` gives the raw records if you would rather plot them yourself — see [Inspecting the parameter trace](/optimize/api#inspecting-the-parameter-trace) for the schema. Neither needs the element's job ID; the result object resolves that for you. ### Data-fit model-vs-data plot data `fit.plot_fit_results()` above covers the common case. Drop to `client.job.get_plot_data` when you want the raw traces — to feed your own plotting stack, or to refetch detail as a user zooms. It returns the model-vs-data traces for a data-fit job — the same overlay Studio renders on the fit's results page. A data-fit re-runs a validation on its best-fit parameters and stores the overlay in its own metadata, so you can fetch it directly from the fit's job ID without adding a separate `Validation` element or parsing the raw metadata blob. The endpoint is keyed by job ID, which the elements listing carries: ```python theme={null} elements = client.get(f"/pipelines/{submission.id}/elements") fit_job_id = next(e["job_id"] for e in elements if e["name"] == "fit") plot = client.job.get_plot_data( fit_job_id, objective_name="1C", # key used in the DataFit's objectives mapping ) ``` Traces are decimated server-side to at most `max_points` points per series (default `2000`, range `100`–`80000`). For semantic zoom — refetching more detail as a user zooms in — pass `x_min` and `x_max` set to the current viewport: ```python theme={null} zoomed = client.job.get_plot_data( fit_job_id, objective_name="1C", x_min=1200.0, x_max=1800.0, max_points=5000, ) ``` | Parameter | Description | | ---------------- | --------------------------------------------------------------------------------------- | | `job_id` | The data-fit job whose overlay to fetch. | | `objective_name` | Key in the `DataFit`'s `objectives` mapping (for example, `"1C"` in the example above). | | `plot_index` | Index into the objective's list of plots when it defines several. Defaults to `0`. | | `max_points` | Maximum points returned per trace (`100`–`80000`). Defaults to `2000`. | | `x_min`, `x_max` | Inclusive x-range bounds. Omit for the full range. | ## Listing pipelines ```python theme={null} # Defaults to the project on Ionworks(...) or IONWORKS_PROJECT_ID pipelines = client.pipeline.list() # Override the project for a single call pipelines = client.pipeline.list(project_id="other-project-id") # Limit the number of results pipelines = client.pipeline.list(limit=10) # Filter, order, and paginate server-side active = client.pipeline.list(status="in.(pending,running)") matches = client.pipeline.list(name="ilike.%diffevo%") recent = client.pipeline.list(order_by="created_at", order="desc", offset=25) ``` `list()` accepts `id`, `name`, `description`, `status`, `created_by_email`, `created_at` and `updated_at` (plus their `_gt` / `_lt` range variants), `order_by`, `order`, `limit`, and `offset`. The result is a `PaginatedList`: it behaves like a list — iterate it, index it, call `len()` on it — and also carries `.items`, `.count`, and `.total`, where `.total` counts every matching pipeline rather than the rows in this page. ## Getting a single submission ```python theme={null} submission = client.pipeline.get(submission.id) print(submission.status) # "pending", "running", "completed", or "failed" ``` ## SimplePipeline A [`SimplePipeline`](/guide/pipelines/simple-pipelines) is a lightweight alternative to `Pipeline` for workflows with **at most one expensive element** — a single `DataFit`, `ArrayDataFit`, or `Validation`. It runs fire-and-forget and returns a flat result containing `parameter_values`, `cost`, and (for validation) `summary_stats`. Submit and poll it through `client.simple_pipeline`. ### Building the config `SimplePipeline` inherits everything from `Pipeline` and adds client-side validation that rejects configs with more than one expensive element: ```python theme={null} import ionworks_schema as iws objective = iws.objectives.CurrentDriven( data_input="file:path/to/discharge.csv", options={"model": "SPM"} ) pipeline = iws.SimplePipeline( elements={ "initial_params": iws.direct_entries.DirectEntry( parameters={"Negative particle diffusivity [m2.s-1]": 2e-14}, ), "fit": iws.DataFit( objectives={"cycle": objective}, parameters={ "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", bounds=(1e-14, 1e-13), initial_value=2e-14, ) }, cost=iws.costs.RMSE(), optimizer=iws.parameter_estimators.ScipyDifferentialEvolution( maxiter=10 ), ), }, name="My Fit", ) config = pipeline.to_config() ``` If you pass more than one `DataFit`, `ArrayDataFit`, or `Validation` element, `SimplePipeline` raises a `ValueError` immediately — no need to wait for a server-side rejection. ### Submitting and polling ```python theme={null} from ionworks import Ionworks client = Ionworks() # Submit — pass the schema instance directly (a dict from .to_config() also works) sp = client.simple_pipeline.create(pipeline, name=pipeline.name) # sp.status == "pending" # Wait for completion (polls automatically) result = client.simple_pipeline.wait_for_completion(sp.id, timeout=600) # Read results print(result.result["parameter_values"]) # {"Negative particle diffusivity [m2.s-1]": 5.3e-14} print(result.result["cost"]) ``` Like `client.pipeline.wait_for_completion`, this also accepts `poll_interval` (seconds between polls, default `2`), `verbose` (print status updates, default `True`), and `raise_on_failure` (default `True`; pass `False` to get the terminal response back instead of raising) keyword arguments, and returns once the run reaches a terminal status — `completed`, `failed`, or `canceled`. `client.simple_pipeline.create()` mirrors `client.pipeline.create()` — it accepts either an `iws.SimplePipeline` schema instance or the dict returned by `.to_config()`. Prefer the schema instance: you don't need to call `.to_config()` yourself, and shape errors surface locally when you build the schema object. A raw dict is forwarded as-is, so a malformed dict is only rejected server-side as an HTTP 422. ### Listing, filtering, and sorting `list` returns a `PaginatedList`, the same type `client.pipeline.list()` returns: it behaves like a list and also carries `items`, `count`, and `total`. String filters accept either an exact value or an operator-prefixed expression such as `ilike.%foo%` (case-insensitive contains) or `in.(completed,failed)` (match any of a set). ```python theme={null} # Newest 25 simple pipelines in the default project page = client.simple_pipeline.list(limit=25) # Only currently active runs active = client.simple_pipeline.list(status="in.(pending,running)") # Name contains "diffevo" (case-insensitive) matches = client.simple_pipeline.list(name="ilike.%diffevo%") # Created in the last week, oldest first recent = client.simple_pipeline.list( created_at_gt="2026-05-08", created_at_lt="2026-05-15", order_by="created_at", order="asc", ) ``` ### Execution options `data_fit` elements in simple pipelines are evaluated in parallel using the same distributed worker pool as regular pipelines. No extra configuration is required — set `optimizer.population_size` as usual and the server fans the population evaluations out across workers. Pass an `options` dict to `create` to control runtime execution behavior for the submitted pipeline. Options are submission metadata — they affect how the server runs the job but are not stored as part of the pipeline config. | Option | Type | Default | Effect | | ----------------------- | -------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `live_progress_updates` | `bool \| None` | `None` (worker picks a sensible default for the job type) | When `True`, the worker writes checkpoint progress to the database during execution so you can poll intermediate progress. When `False`, checkpoints are skipped for better performance. | ```python theme={null} sp = client.simple_pipeline.create( config, name="NMC622 diffusivity fit", options={"live_progress_updates": False}, # skip checkpointing for speed ) ``` You can also embed `options` (along with `project_id`, `name`, or `description`) directly in the config dict — `create` lifts them out of the config before submission. Arguments passed explicitly to `create` take precedence over values found in the config. ```python theme={null} config = { "options": {"live_progress_updates": True}, "elements": { ... }, } sp = client.simple_pipeline.create(config) ``` ### Updating, cancelling, and deleting ```python theme={null} # Rename or add a description (PATCH — at least one field is required) client.simple_pipeline.update(sp.id, name="Renamed", description="notes") # Cancel a running pipeline client.simple_pipeline.cancel(sp.id) # Permanently delete the pipeline, its job, and stored config client.simple_pipeline.delete(sp.id) ``` ### Handling errors ```python theme={null} from ionworks.errors import IonworksError try: result = client.simple_pipeline.wait_for_completion(sp.id) except TimeoutError: # Still running after the timeout — fetch the latest status to decide current = client.simple_pipeline.get(sp.id) print(f"Still {current.status}") except IonworksError as e: # Ended in "failed" or "canceled" — the message names which, with the error print(e) ``` ### Validation pipelines `SimplePipeline` also supports a single `Validation` element. The result includes `summary_stats` alongside `parameter_values`. ```python theme={null} objective = iws.objectives.CurrentDriven( data_input="file:path/to/cycle.csv", options={"model": "SPM"} ) validation_pipeline = iws.SimplePipeline( elements={ "validate": iws.Validation( objectives={"cycle": objective}, ), }, name="Validate fitted model", ) sp = client.simple_pipeline.create( validation_pipeline, name=validation_pipeline.name ) result = client.simple_pipeline.wait_for_completion(sp.id, timeout=600) print(result.result["summary_stats"]) ``` `result.result_object` gives the same run as a typed result object, with the `plot_fit_results()` method and the lazy `overlay` / `series` channels described under [Pipeline results](#retrieving-results). A validation carries no optimizer trace, so `plot_trace()` raises on this one: ```python theme={null} validation = result.result_object validation.plot_fit_results() ``` ## End-to-end example ```python theme={null} import pybamm import ionworks_schema as iws from ionworks import Ionworks client = Ionworks() pipeline = iws.Pipeline( { "known": iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, ), "fit": iws.DataFit( objectives={ "1C": iws.objectives.CurrentDriven( data_input="file:examples/data/chen_synthetic_1C/time_series.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), }, parameters={ "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", initial_value=2e-14, bounds=(1e-14, 1e-13), ), }, cost=iws.costs.RMSE(), optimizer=iws.optimizers.DifferentialEvolution(), ), }, name="SPMe diffusivity fit", ) submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) print(result.element("fit").parameter_values) ``` For more end-to-end examples (entry-only, calculation-only, datafit, validation), see [`packages/ionworks-api/examples/pipeline/`](https://github.com/ionworks/ionworks-api/tree/main/examples/pipeline) in the SDK repo. # Geometry & Capacity Source: https://docs.ionworks.com/build/parameterize/calculations/geometry-capacity Compute electrode capacity, cell mass, cyclable lithium, and stoichiometry windows with ionworks-schema calculations. Geometry and capacity calculations derive cell-level quantities from electrode dimensions and material properties. For the underlying equations and parameter definitions, see the [Geometry & Capacity Guide](/guide/calculations/geometry-capacity). ## Available calculations | Schema | Purpose | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `iws.calculations.ElectrodeCapacity(electrode=..., use_stoich_window=...)` | Solves the capacity equation for whichever quantity (`c_max`, `eps`, `L`, `A`, capacity, …) you didn't supply | | `iws.calculations.CellMass()` | Sums electrode, separator, and current-collector masses from densities and thicknesses | | `iws.calculations.CyclableLithium()` | Total shuttling lithium from electrode capacities and initial stoichiometries | | `iws.calculations.ElectrodeSOH()` | Stoichiometry windows at 0% and 100% SOC from cell capacity and the electrode OCPs | | `iws.calculations.StoichiometryLimitsFromCapacity(electrode=...)` | Minimum/maximum stoichiometries from electrode capacity and excess-capacity values | ## Capacity from electrode geometry ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks known = iws.direct_entries.DirectEntry( parameters={ "Positive electrode active material volume fraction": 0.65, "Positive electrode thickness [m]": 80e-6, "Electrode area [m2]": 0.1, "Maximum concentration in positive electrode [mol.m-3]": 51217.0, # … stoichiometry limits if use_stoich_window=True }, ) q_pos = iws.calculations.ElectrodeCapacity(electrode="positive") pipeline = iws.Pipeline({"known": known, "Q_pos": q_pos}) client = Ionworks() submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) ``` Supply any five of the six parameters in the capacity equation and `ElectrodeCapacity` returns the missing one. Set `use_stoich_window=True` when the capacity corresponds to a voltage window rather than the full material limit. ## Mass and energy density ```python theme={null} import ionworks_schema as iws mass = iws.calculations.CellMass() pipeline = iws.Pipeline({ "known": known, "cell_mass": mass, }) ``` The result adds `"Cell mass [kg]"` to the parameter set so downstream calculations (specific heat capacity, gravimetric energy density) can use it. ## Stoichiometry windows and cyclable lithium ```python theme={null} import ionworks_schema as iws pipeline = iws.Pipeline({ "known": known, "Q_pos": iws.calculations.ElectrodeCapacity(electrode="positive"), "Q_neg": iws.calculations.ElectrodeCapacity(electrode="negative"), "esoh": iws.calculations.ElectrodeSOH(), "q_li": iws.calculations.CyclableLithium(), }) ``` `ElectrodeSOH` reads the cell capacity and electrode OCPs and produces the stoichiometries at 0% and 100% SOC for both electrodes. `CyclableLithium` then combines those with the electrode capacities to give the total lithium that shuttles during cycling. ### Composite electrodes `StoichiometryLimitsFromCapacity` needs a `usable capacity` when either electrode is composite — that is, when its `particle phases` option contains `"2"`, as it does for a graphite–silicon anode: ```python theme={null} iws.calculations.StoichiometryLimitsFromCapacity( electrode="negative", options={"particle phases": ["2", "1"], "usable capacity": 4.85}, ) ``` Omitting it is rejected when the configuration is validated, before the pipeline is submitted. `usable capacity` is per direction. A joint-hysteresis fit uses a different usable capacity on charge than on discharge, so run one calculation per direction with that direction's value rather than sharing a single number between them. The capacity equation, mass balance, and N/P-ratio theory. How calculations chain with direct entries and data fits. # Piecewise Interpolants Source: https://docs.ionworks.com/build/parameterize/calculations/piecewise Build smooth piecewise interpolants for SOC- and temperature-dependent parameters with ionworks-schema. Piecewise interpolants produce smooth, differentiable functions of one or two breakpoint variables — useful for SOC-dependent diffusivities, temperature-dependent transport, and OCP curves. For the math (smooth heaviside, knots vs slopes, blending), see the [Piecewise Interpolants Guide](/guide/calculations/piecewise). ## 1D piecewise interpolant ```python theme={null} import ionworks_schema as iws D_neg = iws.direct_entries.PiecewiseInterpolation1D( base_parameter_name="Negative particle diffusivity [m2.s-1]", breakpoint_values=[0.0, 0.3, 0.7, 1.0], breakpoint_parameter_name="SOC", smoothing=1e-4, ) values = iws.direct_entries.DirectEntry( parameters={ "Negative particle diffusivity at SOC 0 [m2.s-1]": 3.9e-14, "Negative particle diffusivity at SOC 0.3 [m2.s-1]": 5.2e-14, "Negative particle diffusivity at SOC 0.7 [m2.s-1]": 4.8e-14, "Negative particle diffusivity at SOC 1 [m2.s-1]": 3.5e-14, }, ) pipeline = iws.Pipeline({"D_neg values": values, "D_neg": D_neg}) ``` The interpolant reads one parameter per breakpoint (the names follow `" at [units]"`) and produces a smooth function of the breakpoint variable. ## 2D piecewise interpolant For parameters varying with two variables (e.g. SOC and temperature): ```python theme={null} import ionworks_schema as iws D_neg = iws.direct_entries.PiecewiseInterpolation2D( base_parameter_name="Negative particle diffusivity [m2.s-1]", breakpoint1_values=[0.0, 0.5, 1.0], breakpoint1_parameter_name="SOC", breakpoint2_values=[273.15, 298.15, 323.15], breakpoint2_parameter_name="Temperature [K]", smoothing1=1e-4, smoothing2=0.1, ) ``` Use different smoothing parameters when the two axes have very different scales (SOC ∈ \[0, 1] vs Temperature ∈ \[273, 323] K). ## OCP interpolants | Schema | What it builds | | --------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `iws.calculations.OCPDataInterpolant(electrode=...)` | Smooth interpolant from half-cell OCP measurements | | `iws.calculations.OCPMSMRInterpolant(electrode=...)` | Evaluates the MSMR model over a voltage range to create an interpolant | | `iws.calculations.OCPDataInterpolantMSMRExtrapolation(electrode=...)` | Blends measured data inside the data range with MSMR extrapolation outside | ```python theme={null} import ionworks_schema as iws ocp_pos = iws.calculations.OCPDataInterpolantMSMRExtrapolation(electrode="positive") pipeline = iws.Pipeline({"ocp_pos": ocp_pos}) ``` The blended interpolant is the recommended option whenever a simulation might access stoichiometries outside the measured data range — MSMR provides thermodynamically consistent extrapolation at the extremes. ## Choosing an interpolator All 1-D interpolant calculations — OCP interpolants (`OCPDataInterpolant`, `OCPMSMRInterpolant`, `OCPDataInterpolantMSMRExtrapolation`), diffusivity interpolants (`DiffusivityDataInterpolant`, `DiffusivityFromMSMRData`, `DiffusivityFromMSMRFunction`, `ArrheniusDiffusivityFromMSMRData`, `ArrheniusDiffusivityFromMSMRFunction`), and entropic-change interpolants (`EntropicChangeDataInterpolant`, `EntropicChangeFromMSMRFunction`) — accept an `"interpolator"` option that controls how values between data points are evaluated. | Value | Behaviour | When to use | | ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- | | `"linear"` *(default)* | Straight segments between knots | Coarse data, or when you want to avoid overfitting | | `"cubic"` | Cubic-spline interpolation | Smooth physical relationships sampled densely | | `"pchip"` | Monotone cubic (PCHIP) interpolation | Monotonic data (e.g. OCP curves) — prevents the overshoots that plain cubic splines can introduce | ```python theme={null} import ionworks_schema as iws ocp_pos = iws.calculations.OCPDataInterpolantMSMRExtrapolation( electrode="positive", options={"interpolator": "pchip"}, ) ``` For OCP curves and other monotonic data, `"pchip"` typically produces the most physically realistic interpolant: it stays monotonic between knots and avoids the spurious oscillations a cubic spline can introduce near steep features. Smooth heaviside math, knot vs slope parameterisation, MSMR blending. How interpolants chain with direct entries and data fits. # Thermal Properties Source: https://docs.ionworks.com/build/parameterize/calculations/thermal Configure Arrhenius temperature dependence and heat-capacity calculations with ionworks-schema. Thermal calculations cover temperature dependence (Arrhenius fits) and heat-capacity bookkeeping. For the underlying physics, see the [Thermal Calculations Guide](/guide/calculations/thermal). ## Available calculations | Schema | Purpose | | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `iws.calculations.ArrheniusLogLinear(data=..., reference_temperature=...)` | Fits $(k_{\text{ref}}, E_a)$ from a table of $(T, k)$ measurements | | `iws.calculations.SpecificHeatCapacity()` | Converts between lumped cell heat capacity \[J/K] and specific heat \[J/(kg·K)] given the cell mass | | `iws.calculations.LumpedHeatCapacityAndDensity()` | Sets per-component specific heat and density to the cell-level lumped values | ## Fitting Arrhenius parameters ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks arrhenius = iws.calculations.ArrheniusLogLinear( data={ "Temperature [K]": [273, 298, 323, 348], "Negative particle diffusivity [m2.s-1]": [1e-14, 3e-14, 8e-14, 2e-13], }, reference_temperature=298.15, ) pipeline = iws.Pipeline({"D_neg arrhenius": arrhenius}) client = Ionworks() submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) ``` Set `include_func=True` to additionally return an interpolant so the quantity can be evaluated at any temperature, not just the measured ones. The `data` field accepts a column dict (as above), a bare pandas or polars `DataFrame`, or a string reference. Use `"db:"` to reference an uploaded measurement; `"file:..."` and `"folder:..."` are read from your local machine and inlined into the config by the API client on submit, so they work both locally and when you submit a fit to Ionworks — subject to the same 1,000-row inline limit as a bare `DataFrame` (upload a measurement and use `"db:"` for larger datasets). A bare `DataFrame` is auto-wrapped on serialization, so `data=df` and `data={"data": df}` behave the same. ## Specific heat capacity ```python theme={null} import ionworks_schema as iws known = iws.direct_entries.DirectEntry( parameters={ "Cell heat capacity [J.K-1]": 50.0, "Cell mass [kg]": 0.05, }, ) cp = iws.calculations.SpecificHeatCapacity() pipeline = iws.Pipeline({"known": known, "cp": cp}) ``` The result adds `"Cell specific heat capacity [J.kg-1.K-1]"` to the parameter set. ## Lumped thermal model For a single-temperature cell model, `LumpedHeatCapacityAndDensity` propagates the cell-level specific heat and density to each component. Use it after `SpecificHeatCapacity` in pipelines that target a lumped thermal solve: ```python theme={null} pipeline = iws.Pipeline({ "known": known, "cp": iws.calculations.SpecificHeatCapacity(), "lumped": iws.calculations.LumpedHeatCapacityAndDensity(), }) ``` Arrhenius theory, heat generation, lumped vs. distributed models. How thermal calcs chain with direct entries and data fits. # Post-fit analysis Source: https://docs.ionworks.com/build/parameterize/data-fitting/analysis Quantify a fit's uncertainty with iws.LinearConfidenceInterval and iws.SobolSensitivity as pipeline elements. A fit gives you parameter values. Post-fit analysis tells you how much to trust them: how tightly the data constrains each parameter, and which parameters the model output actually responds to. Both run as **pipeline elements**, alongside the fit rather than after it in your own code, so the expensive part happens on the platform. For the concepts behind variance-based sensitivity, see the [Sensitivity Analysis Guide](/guide/data-fitting/sensitivity-analysis). ## The two elements | Schema | Answers | | ------------------------------ | ---------------------------------------------------------------------- | | `iws.LinearConfidenceInterval` | How tightly does the data constrain each fitted parameter? | | `iws.SobolSensitivity` | Which parameters does the model output respond to, and which interact? | Both take the same `objectives` and `parameters` as a `DataFit`. They accept only a `PointEstimate` optimizer, because they analyse a fit rather than performing one. ## Adding analysis to a pipeline Place the analysis element after the fit it analyses. When its objectives and parameters match the fit's, it picks up the fitted point automatically — you do not restate the values: ```python theme={null} import ionworks_schema as iws pipeline = iws.Pipeline( { "fit": iws.DataFit(objectives=objectives, parameters=parameters), "intervals": iws.LinearConfidenceInterval( objectives=objectives, parameters=parameters, ), "sensitivity": iws.SobolSensitivity( objectives=objectives, parameters=parameters, ), } ) ``` ## Reading the results Each element returns a typed result, reachable off the pipeline result rather than by reading raw job metadata: ```python theme={null} result = client.pipeline.result(pipeline_id) intervals = result.element("intervals") # iws.ConfidenceIntervalResult sensitivity = result.element("sensitivity") # iws.SensitivityResult ``` Sobol sensitivity is a sampling method, so its cost grows with the number of parameters and the sample count. Start with a small parameter set and widen it once you know which parameters matter. # Objective Functions Source: https://docs.ionworks.com/build/parameterize/data-fitting/objective-functions Configure objectives and cost functions for data fitting with iws.objectives and iws.costs. A `DataFit` has two coupled pieces: * **Objectives** (`iws.objectives.*`) — what experiments to compare model output against. * **Cost** (`iws.costs.*`) — how the per-point disagreements are aggregated into a single number. For the math behind each cost, see the [Objective Functions Guide](/guide/data-fitting/objective-functions). ## Available cost functions | Schema | Formula | When to use | | ------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iws.costs.SSE()` | $\sum_i r_i^2$ | Default; works with every optimiser | | `iws.costs.MSE()` | $\frac{1}{N}\sum_i r_i^2$ | Scale-aware mean of squared residuals | | `iws.costs.RMSE()` | $\sqrt{\frac{1}{N}\sum_i r_i^2}$ | Interpretable units; scalar-only (won't work with residual-array optimisers) | | `iws.costs.MAE()` | $\frac{1}{N}\sum_i \lvert r_i \rvert$ | Robust to outliers | | `iws.costs.Max()` | $\max_i \lvert r_i \rvert$ | Minimise the worst-case (largest absolute) residual | | `iws.costs.Wasserstein()` | $\frac{1}{N}\sum_i \lvert \tilde y_{\text{model},i} - \tilde y_{\text{data},i} \rvert$ | Match distributions (sorted samples) rather than point-wise time series. Set `position_variable` and `weight_variable` for [weighted point-cloud mode](#wasserstein-weighted-point-cloud-mode) | For MLE, see `iws.costs.GaussianLogLikelihood` — it accepts per-variable noise standard deviations or can estimate them alongside the fitting parameters. It produces a Gaussian negative log-likelihood suitable for Bayesian and MAP estimation. ## Wiring a cost into a fit ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "1C": iws.objectives.CurrentDriven( data_input="file:.../1C.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), }, parameters={ "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", initial_value=2e-14, bounds=(1e-14, 1e-13), ), }, cost=iws.costs.RMSE(), ) ``` If `cost` is omitted, the optimizer's default cost function is used (typically a least-squares form). `cost` accepts a cost schema instance (e.g. `iws.costs.RMSE()`) or a config dict with an explicit `type` key (e.g. `{"type": "RMSE"}`). A bare name string like `cost="RMSE"` is rejected with a validation error — wrap it as `{"type": "RMSE"}` instead. ## Wasserstein weighted point-cloud mode By default `iws.costs.Wasserstein()` compares the model and data samples for each objective variable with uniform weights (sorted point-wise comparison). Set both `position_variable` and `weight_variable` to switch to **weighted point-cloud mode**: one variable supplies the positions, the other supplies the (sign-stripped, renormalised) weights, and a single Wasserstein-1 distance is computed per objective. Use this when you want to match a *density by position* rather than sample-by-sample values — for example, lining up dQ/dV peaks in voltage rather than penalising every dQ/dV residual. Both `iws.objectives.MSMRFullCell` and `iws.objectives.ElectrodeBalancing` expose the matching `Differential capacity [Ah/V]` values alongside their `Voltage [V] (dQdU)` masked-axis sibling, so either can drive a weighted point-cloud fit: ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives={ "ocp": iws.objectives.ElectrodeBalancing( data_input="file:.../ocv.csv", options={ "objective variables": [ "Differential capacity [Ah/V]", "Voltage [V] (dQdU)", ], }, ), }, parameters={...}, cost=iws.costs.Wasserstein( position_variable="Voltage [V] (dQdU)", weight_variable="Differential capacity [Ah/V]", ), ) ``` `position_variable` and `weight_variable` must be set together — providing only one raises a validation error. Weights are taken as absolute values and renormalised internally, so sign conventions on dQ/dV don't matter. Residual-array output is not available in this mode. ## `ElectrodeBalancing` options for OCV fitting `ElectrodeBalancing` accepts the following keys in its `options` dict to control how the full-cell OCV is processed before the objective is evaluated. These apply regardless of which cost function the fit uses (not only weighted `Wasserstein`): | Option | Type | Default | Purpose | | --------------------- | ----------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `objective variables` | list of str | `["Voltage [V]", "Differential voltage [V/Ah]"]` | Variables compared between model and data. Add `"Differential capacity [Ah/V]"` to also emit model dQ/dU on the data voltage grid (plus the masked-axis siblings `"Voltage [V] (dQdU)"` and `"Capacity [A.h] (dQdU)"`) for weighted Wasserstein costs. | | `dUdQ cutoff` | float \| None | `None` | Drop data points whose `dU/dQ` exceeds this value — useful for masking the near-vertical regions at the voltage limits. | | `dQdU cutoff` | float \| None | `None` | Drop data points whose `dQ/dU` exceeds this value — useful for masking flat OCV regions where `dQ/dU` diverges. Negative or zero values are always dropped so the resulting weights stay non-negative for Wasserstein. | | `direction` | `"charge"` \| `"discharge"` \| None | `None` | Direction of the OCV scan. `None` makes no directional assumption. | | `GITT` | bool | `False` | Treat the data as sparse GITT samples and upsample by interpolation before computing the derivatives. | | `dQdU model axis` | bool | `False` | When `True`, additionally emit dQ/dV on the model's own full-window voltage axis as `"Differential capacity [Ah/V] (model axis)"` / `"Voltage [V] (model axis)"` — see [Aligning dQ/dV peaks on the model voltage axis](#aligning-dqdv-peaks-on-the-model-voltage-axis). | ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives={ "ocp": iws.objectives.ElectrodeBalancing( data_input="file:.../ocv.csv", options={ "direction": "discharge", "GITT": True, "dUdQ cutoff": 1.0, "dQdU cutoff": 50.0, "objective variables": [ "Differential capacity [Ah/V]", "Voltage [V] (dQdU)", ], }, ), }, parameters={...}, cost=iws.costs.Wasserstein( position_variable="Voltage [V] (dQdU)", weight_variable="Differential capacity [Ah/V]", ), ) ``` ## Scoping a cost with `calculation_structure` By default every cost on a `DataFit` consumes every objective and every objective variable in the outputs. Set `calculation_structure` on a cost to scope it explicitly: a mapping from objective name to the list of variable names that cost should compute, or `None` to compute all of that objective's variables (an empty list computes none). Objectives you leave out of the mapping are not dropped. Inside a `DataFit` each unscoped objective is bound to all of its variables — the same as mapping it to `None` — so scoping one objective (e.g. `{"ocp": ["Voltage [V]"]}` while a `"cc"` objective also exists) still computes `"cc"` in full. Use this when one cost should only see a subset of variables — most commonly when you pair a per-variable cost (e.g. `SSE`) with a weighted `Wasserstein`. The Wasserstein owns the dQ/dV variables (whose model and data sides may have different lengths by construction), and the SSE is scoped to skip them so the lengths never collide. ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives={ "ocp": iws.objectives.ElectrodeBalancing( data_input="file:.../ocv.csv", options={ "objective variables": [ "Voltage [V]", "Differential capacity [Ah/V] (model axis)", "Voltage [V] (model axis)", ], "dQdU model axis": True, }, ), }, parameters={...}, cost=[ iws.costs.SSE( calculation_structure={"ocp": ["Voltage [V]"]}, ), iws.costs.Wasserstein( position_variable="Voltage [V] (model axis)", weight_variable="Differential capacity [Ah/V] (model axis)", calculation_structure={ "ocp": [ "Voltage [V] (model axis)", "Differential capacity [Ah/V] (model axis)", ], }, ), ], ) ``` `calculation_structure` replaces the deprecated `objective_names` field (a flat list of objective names with no per-variable control). Specifying both on the same cost raises a validation error. ### Length-mismatch warning Element-wise costs (`SSE`, `MSE`, `RMSE`, `MAE`, `Max`) combine the model and data arrays point-by-point, so a variable whose model and data sides have different lengths almost never gives a meaningful score. At fit setup, `DataFit` checks the shapes of every variable each cost is configured to score and emits a `UserWarning` for each mismatch — for example: ```text theme={null} UserWarning: variable 'Voltage [V] (model axis)' of objective 'ocp' has mismatched model/data shapes ((512,) vs (128,)). An element-wise cost will combine them point-by-point, which is almost never intended. Scope the cost with an explicit `calculation_structure` so each variable is compared against a matching-length counterpart. ``` The check runs once at fit setup (not on every objective evaluation), so it has no impact on fit performance. When you see this warning, scope the cost with [`calculation_structure`](#scoping-a-cost-with-calculation_structure) so it only sees variables whose model and data lengths match — and route any model-axis variables to a `Wasserstein` cost (or another distribution metric) instead. Distribution costs like `Wasserstein` are skipped by the check, since unequal-length sample sets are expected there. ## Aligning dQ/dV peaks on the model voltage axis `iws.objectives.ElectrodeBalancing` can emit dQ/dV on the **model's own full-window voltage axis** in addition to (or instead of) the data voltage grid. Set `dQdU model axis: True` in `options` and add the two model-axis variables — `"Differential capacity [Ah/V] (model axis)"` and `"Voltage [V] (model axis)"` — to `objective variables`. Use this when you want a weighted cost (typically `Wasserstein` in [point-cloud mode](#wasserstein-weighted-point-cloud-mode)) to *position-shift* — i.e. align dQ/dV peaks in voltage rather than residual-by-residual on the data grid. The model and data sides have different lengths by construction, so only a weighted cost should consume them; pair them with a sibling per-variable cost scoped via `calculation_structure` (see above) to keep the rest of the fit honest. The existing data-axis variables (`"Differential capacity [Ah/V]"` plus the masked siblings `"Voltage [V] (dQdU)"` / `"Capacity [A.h] (dQdU)"`) remain available — both axes can be requested side by side. ## Available objectives | Schema | Use for | | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iws.objectives.CurrentDriven(data_input=..., options={...})` | Time-series voltage vs. current loads (drive cycles, custom loads) | | `iws.objectives.Pulse(data_input=..., options={...})` | Pulse experiments — GITT, HPPC, ICI — with optional feature-extraction variants | | `iws.objectives.OCPHalfCell(electrode=..., data_input=...)` | Half-cell OCP curves | | `iws.objectives.MSMRHalfCell(...)` | Fit MSMR parameters to half-cell data | | `iws.objectives.MSMRFullCell(...)` | Fit MSMR parameters to full-cell data. Supports `Differential voltage [V/Ah]` and `Differential capacity [Ah/V]` as objective variables | | `iws.objectives.ElectrodeBalancing(...)` | Stoichiometry windows from full-cell discharge. Supports `Differential voltage [V/Ah]` and `Differential capacity [Ah/V]` as objective variables — see [`ElectrodeBalancing` options](#electrodebalancing-options-for-ocv-fitting) | | `iws.objectives.EIS(...)` | Electrochemical impedance spectra. Supports refined particle meshes via `simulation_kwargs` and multi-SOC joint fits — see [`EIS` options](#eis-fitting-impedance-spectra) | | `iws.objectives.Resistance(...)` | DC resistance extracted from pulse data | | `iws.objectives.CalendarAgeing(...)` / `iws.objectives.CycleAgeing(...)` | Ageing curves | Combine several by passing a `dict[str, objective]` to `DataFit.objectives`. The objectives that run a simulation — `CurrentDriven`, `Pulse`, `EIS`, `CalendarAgeing`, and `CycleAgeing` — need a model to simulate against, and construction fails without one. Give it as `options={"model": pybamm.lithium_ion.SPMe()}`, or point at a model stored on the platform with `options={"parameterized_model_id": ""}`. The remaining objectives fit data directly and take no model. `MSMRHalfCell` and `MSMRFullCell` project fitted host-site fractions `Xj` onto the bounded simplex so they sum to exactly 1 and stay within bounds. `MSMRFullCell` always does this; on `MSMRHalfCell` it is the `"project"` default of the `constrain Xj method` option, and the recommended setting. `constrain Xj method` also accepts `"reformulate"` and `"explicit"`, which are kept for reproducing older fits. They enforce the constraint more weakly: `"reformulate"` replaces the final `Xj` with the complement of the others, and `"explicit"` applies a soft constraint the optimizer can trade away. Neither can now pass unnoticed — both objectives check the fitted fractions after the fit and fail when `|sum(Xj) - 1|` exceeds 0.05 or any `Xj` is negative, warning above `1e-6`. Set the `DataFit` `"validate"` option to `False` to skip the check and get the unphysical values back. Set `penalize Xj complement bounds` on `MSMRHalfCellOptions` to additionally penalize a reformulated complement that falls outside the final `Xj` bounds. ## `GITTModel`: diffusion-only model for GITT and pulse fits `GITTModel` is a fitting-only model intended for extracting **solid-phase diffusivities** (and a single lumped ohmic resistance) from GITT or pulse-relaxation measurements. It solves x-averaged spherical particle diffusion in each modelled electrode, with the surface flux set by the applied current, and computes the cell voltage from the electrode open-circuit potentials evaluated at the particle-surface stoichiometries, minus an ohmic drop through a lumped `"Ohmic resistance [Ohm]"` parameter. There are no reaction kinetics (Butler-Volmer), no electrolyte dynamics, and no thermal effects — all parameters are constant except the OCPs. Use it when you want fast, well-conditioned fits to diffusion-dominated portions of GITT or pulse data, and reach for `SPM` / `SPMe` / `DFN` when you need a full physics simulation. Select the cell configuration via the `"working electrode"` option: | `"working electrode"` | Configuration | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"both"` (default) | Full cell. Both electrodes are modelled. A positive (discharge) current delithiates the negative electrode and lithiates the positive electrode. | | `"positive"` | Half-cell against a lithium-metal counter electrode (pybamm half-cell convention). Only the working electrode is modelled. A positive current lithiates the working electrode (discharge for a cathode material, charge for an anode material). Anode-material half cells also use `"positive"` — rename the anode's parameters to the positive convention first. | Each modelled electrode is parameterised with the standard full-cell parameter names (thickness, active material volume fraction, particle radius, diffusivity, OCP, maximum and initial concentrations), plus the current function, electrode cross-sectional area, initial temperature, and `"Ohmic resistance [Ohm]"`. ### Fitting a full-cell GITT measurement ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives={ "gitt": iws.objectives.Pulse( data_input="file:.../gitt.csv", options={ "model": iws.models.GITTModel(), }, ), }, parameters={ "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", initial_value=2e-14, bounds=(1e-15, 1e-12), ), "Positive particle diffusivity [m2.s-1]": iws.Parameter( "Positive particle diffusivity [m2.s-1]", initial_value=2e-15, bounds=(1e-16, 1e-13), ), "Ohmic resistance [Ohm]": iws.Parameter( "Ohmic resistance [Ohm]", initial_value=0.02, bounds=(1e-3, 1e-1), ), }, ) ``` ### Fitting a half-cell pulse measurement Pass `"working electrode": "positive"` to model a single electrode against a lithium-metal counter. Only the working-electrode parameters are needed. ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives={ "pulse": iws.objectives.Pulse( data_input="file:.../half_cell_pulse.csv", options={ "model": iws.models.GITTModel( options={"working electrode": "positive"}, ), }, ), }, parameters={ "Positive particle diffusivity [m2.s-1]": iws.Parameter( "Positive particle diffusivity [m2.s-1]", initial_value=2e-15, bounds=(1e-16, 1e-13), ), "Ohmic resistance [Ohm]": iws.Parameter( "Ohmic resistance [Ohm]", initial_value=0.02, bounds=(1e-3, 1e-1), ), }, ) ``` `"working electrode"` only accepts `"both"` or `"positive"` — anything else fails schema validation. Any other keys in `options` are forwarded to the underlying battery-model options for parameter bookkeeping; they do not change the diffusion-only physics. ## `EIS`: fitting impedance spectra `iws.objectives.EIS` compares model impedance against measured electrochemical impedance spectroscopy (EIS) data in the frequency domain, using PyBaMM's `EISSimulation`. Use it to identify kinetic parameters — the two electrodes' reference exchange-current density (the charge-transfer arc diameters) and double-layer capacity (the arc frequencies) — plus the ohmic offset, which time-domain discharges alone leave degenerate. The data must contain `Frequency [Hz]`, `Z_Re [Ohm]`, and `Z_Im [Ohm]` columns (capacitive band with `Z_Im < 0`, matching the SDK's [EIS upload validator](/data/format)). The model must be built with `"surface form": "differential"` — `EISSimulation` rejects the default form, and `"algebraic"` drops the double-layer capacity being fitted. ### Refining the mesh via `simulation_kwargs` `options["simulation_kwargs"]` forwards mesh and discretisation kwargs (`var_pts`, `submesh_types`, `geometry`, `spatial_methods`) straight through to `EISSimulation` — the same shape you already use for `CurrentDriven` / `Pulse` objectives. Use it to refine the particle mesh when the default resolution smears the charge-transfer arc. Time-domain-only keys (`solver`, `solver_kwargs`, `solve_kwargs`, `output_variables`, `experiment`, …) are silently dropped with an info log, so a `simulation_kwargs` dict shared with a time-domain objective is accepted without raising. ```python theme={null} import ionworks_schema as iws import pybamm model = pybamm.lithium_ion.SPMe( options={"surface form": "differential"}, ) fit = iws.DataFit( objectives={ "eis": iws.objectives.EIS( data_input="file:.../eis.csv", options={ "model": model, "simulation_kwargs": { # Refine the positive-particle mesh — same shape as for # CurrentDriven / Pulse objectives. "var_pts": {"r_p": 40}, }, }, ), }, parameters={...}, ) ``` ### Fitting across multiple SOC set-points Fit one `EIS` objective per SOC set-point and combine them in the same `DataFit`. Each objective pins its operating point with an objective-level `Initial SOC [%]` (or `Initial voltage [V]`) parameter, and the shared kinetic parameters are identified jointly across all spectra: ```python theme={null} import ionworks_schema as iws import pybamm model = pybamm.lithium_ion.SPMe( options={"surface form": "differential"}, ) fit = iws.DataFit( objectives={ f"eis_soc{int(soc * 100)}": iws.objectives.EIS( data_input=f"db:", options={"model": model}, parameters={"Initial SOC [%]": soc * 100}, ) for soc in (0.25, 0.50, 0.75) }, parameters={ "Positive electrode reference exchange-current density [A.m-2]": iws.Parameter( "j0ref_p", initial_value=1.0, bounds=(0.1, 10.0), ), "Positive electrode double-layer capacity [F.m-2]": iws.Parameter( "C_dl_p", initial_value=0.1, bounds=(0.01, 1.0), ), }, ) ``` ## Specifying `data_input` Every objective's `data_input` (and any other `data` field on a calculation or interpolant) accepts the same set of forms: * A reference string: `"db:"` to reference an uploaded measurement. `"file:..."` and `"folder:..."` are read from your local machine and inlined into the config by the API client on submit, so they work both locally and when you submit a fit to Ionworks — subject to the same 1,000-row inline limit as a bare `DataFrame`. For larger datasets, upload a measurement and reference it with `"db:"`. * An `ionworksdata.DataLoader` (local or fetched with `DataLoader.from_db(...)`). * A bare pandas or polars `DataFrame` of pre-loaded columns. ```python theme={null} import pybamm import ionworks_schema as iws import pandas as pd df = pd.DataFrame( { "Time [s]": [...], "Voltage [V]": [...], "Current [A]": [...], } ) obj = iws.objectives.CurrentDriven( data_input=df, options={"model": pybamm.lithium_ion.SPMe()}, ) ``` When a bare `DataFrame` is passed, it is auto-wrapped on serialization to match the parser's expected `{"data": }` shape — so `data_input=df` and `data_input={"data": df}` behave the same. String paths and already-wrapped dicts are left untouched. A string `data_input` must start with `db:`, `file:`, or `folder:` to say where the data is read from — a bare path such as `"data/1C.csv"` is rejected when the objective is constructed. Write it as `"file:data/1C.csv"`. A dict `data_input` is matched against the payload shapes above by its keys — `{"time_series": ..., "steps": ...}`, `{"data": ..., "options": ...}`, or `{"data": ..., "metadata": ...}` — and validated strictly, so a misspelt key or loading option is reported when the objective is constructed rather than being ignored. A dict matching none of those shapes is treated as a plain mapping of column names to values. `time_series`, `steps`, `data`, `options`, and `metadata` are therefore reserved: a column literally named one of them is read as a payload key instead of as a column. Inline DataFrames are capped at 1,000 rows per call. For larger datasets, upload as a measurement and reference it by ID instead. See [inline time series size limit](/data/reading#inline-time-series-size-limit). ## Generating a CycleAgeing experiment from data `iws.objectives.CycleAgeing` normally requires an explicit `pybamm.Experiment` describing the cycling protocol. When the protocol is already encoded in the cycler step information attached to your data, set `experiment="from data"` to skip rebuilding it by hand. The experiment is generated lazily, when the fit starts, by calling `DataLoader.generate_experiment()` on the loaded step table. Use this when: * The fitted data carries its own step information (a local `ionworksdata.DataLoader`, or one fetched with `DataLoader.from_db(...)`). * You want the simulated protocol to track the measurement protocol exactly — including any per-step current, voltage limits, or durations recorded by the cycler. ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "ageing": iws.objectives.CycleAgeing( data_input="db:", options={ "model": pybamm.lithium_ion.SPM(options={"SEI": "ec reaction limited"}), "experiment": "from data", "objective variables": ["LLI [%]"], }, ), }, parameters={...}, ) ``` If the data you are fitting against (for example, a per-cycle summary table) is a different object from the measurement that defines the protocol, pass a separate `DataLoader` as `experiment` instead — the steps come from that loader, while the residuals are still computed against `data_input`: ```python theme={null} import pybamm import ionworksdata as iwdata import ionworks_schema as iws protocol = iwdata.DataLoader.from_db("") fit = iws.DataFit( objectives={ "ageing": iws.objectives.CycleAgeing( data_input="db:", options={ "model": pybamm.lithium_ion.SPM(), "experiment": protocol, "objective variables": ["LLI [%]"], }, ), }, parameters={...}, ) ``` `experiment="from data"` requires `data_input` to resolve to a `DataLoader` (or a dict whose `"data"` entry is a `DataLoader`) that carries step information. When you pass a separate `DataLoader` as `experiment`, that loader must carry the step information instead. Either way, configurations missing steps fail fast at objective construction with a clear error, before any simulation runs. ## Tuning the auto-built solver Simulation-backed objectives (`CurrentDriven`, `Pulse`, `CalendarAgeing`, `CycleAgeing`, `MSMRFullCell`, …) build an `IonworksSolver` for you when no explicit `solver` is provided. Pass `solver_kwargs` inside `simulation_kwargs` to override individual pieces of that default without restating the rest: * Nested `options` are merged over the default IDAKLU options. For example, `{"options": {"compile": True}}` flips on model compilation but keeps every other tuned option. * Other top-level keys (`atol`, `rtol`, `on_extrapolation`, …) override the corresponding default solver kwargs. `solver_kwargs` is ignored (with a warning) when an explicit `solver` is supplied — configure those on the solver instance directly. It is also ignored when the model's default solver isn't IDAKLU-based. ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "1C": iws.objectives.CurrentDriven( data_input="file:.../1C.csv", options={ "model": pybamm.lithium_ion.SPMe(), "simulation_kwargs": { "solver_kwargs": { "options": {"compile": True}, "atol": 1e-8, }, }, }, ), }, parameters={...}, ) ``` Enabling `compile` ahead of time (`{"options": {"compile": True}}`) trades a one-off compilation cost for faster repeated evaluations — useful when the same objective is solved many times during a fit or sweep. ### Forwarding kwargs to the runtime solve `simulation_kwargs` also accepts `solve_kwargs`, a dict forwarded to the runtime `sim.solve(...)` call on every objective evaluation. Use it for arguments that belong on the solve itself rather than the solver — for example `starting_solution` to warm-start from a previous solution, or any other `pybamm.Simulation.solve` argument. * `solve_kwargs` is applied regardless of whether the objective auto-built the solver or you supplied an explicit `solver`. It is the recommended way to pass solve-time arguments that work with any solver. * `solver_kwargs` (above) tunes the auto-built solver at *construction* time; `solve_kwargs` configures each *solve call*. The two are independent and can be combined. * Keys the objective controls directly — `inputs`, `initial_soc`, `t_eval`, `t_interp`, `frequencies` — are reserved and raise a `ValueError` if passed via `solve_kwargs`. * For `CycleAgeing`, `save_at_cycles` is derived automatically from the metrics; any value passed via `solve_kwargs` is ignored with a warning so that the cycles required by the metrics are preserved. * For `CurrentDriven` fits against models with **open-circuit potential hysteresis** enabled, pass `"direction": "charge"` or `"direction": "discharge"` via `solve_kwargs` to seed the initial hysteresis state on the corresponding OCP branch. Without a direction, the model starts on its default branch, which can bias the first few seconds of the predicted voltage — and, for short experiments, the fitted parameters. Match `direction` to whichever half-cycle the dataset represents (typically the sign of the measured current). ```python theme={null} import pybamm import ionworks_schema as iws # SPMe with a current-sigmoid hysteresis submodel on the negative OCP model = pybamm.lithium_ion.SPMe( options={"open-circuit potential": ("current sigmoid", "single")}, ) fit = iws.DataFit( objectives={ "discharge_1C": iws.objectives.CurrentDriven( data_input="file:.../1C_discharge.csv", options={ "model": model, "simulation_kwargs": { # Start on the discharge branch of the hysteresis loop. "solve_kwargs": {"direction": "discharge"}, }, }, ), }, parameters={...}, ) ``` ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "pulse": iws.objectives.Pulse( data_input="file:.../pulse.csv", options={ "model": pybamm.lithium_ion.SPMe(), "simulation_kwargs": { # Tunes the auto-built solver (construction time): "solver_kwargs": {"options": {"compile": True}}, # Forwarded to every sim.solve(...) call (runtime). # prior_solution is a pybamm.Solution you obtained from an # earlier sim.solve(...) — substitute your own: "solve_kwargs": {"starting_solution": prior_solution}, }, }, ), }, parameters={...}, ) ``` ### `CycleAgeing`: automatic `store_first_last` for first/last-only metrics `CycleAgeing` lets you supply `metrics` — a mapping from each objective variable to a `.by_cycle()` metric that pulls the value of interest out of the simulation. Defaults are provided for `"LLI [%]"`, `"LAM_ne [%]"`, and `"LAM_pe [%]"`, all of which read a single per-step sample. When *every* metric in that mapping reads only the first or last sample of a step — i.e. the defaults, or any `First`/`Last` `.by_cycle()` metric — `CycleAgeing` now defaults `solver_kwargs["store_first_last"]` to `True`. The solver then stores only the endpoints of each step, which is far more memory-light for long cycling solves and produces identical results for these metrics. The flag is only auto-set when it is safe to do so: * Metrics that read interior points (e.g. `Mean(...).by_cycle()`) leave the default off so no samples are dropped. * Composed metrics (arithmetic of `First`/`Last`) are conservatively left alone. * An explicit `store_first_last` in `solver_kwargs` is always respected. * Supplying your own `solver` skips solver-kwargs injection entirely (as elsewhere). ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "ageing": iws.objectives.CycleAgeing( data_input="file:.../ageing.csv", options={ "model": pybamm.lithium_ion.SPM(), "experiment": "from data", "objective variables": ["LLI [%]", "LAM_ne [%]"], # Defaults already read first/last only, so store_first_last # is enabled automatically. Override explicitly when needed: # "simulation_kwargs": { # "solver_kwargs": {"store_first_last": False}, # }, }, ), }, parameters={...}, ) ``` ### `CycleAgeing`: unified experiment model for cheaper cycling Long cycling protocols repeat the same handful of steps thousands of times. By default pybamm builds a separate switching model per step, which is wasteful when every cycle is the same shape. `CycleAgeing` now defaults `simulation_kwargs["experiment_model_mode"]` to `"unified"`, so a single switching model covers the whole experiment — much cheaper to build and solve for repeated cycling, with identical results. The default is applied whenever an experiment is available (passed as the `experiment` option, or generated from data via [`experiment="from data"`](#generating-a-cycleageing-experiment-from-data)). It is only a default: any explicit `experiment_model_mode` you pass in `simulation_kwargs` is respected. ```python theme={null} import pybamm import ionworks_schema as iws fit = iws.DataFit( objectives={ "ageing": iws.objectives.CycleAgeing( data_input="file:.../ageing.csv", options={ "model": pybamm.lithium_ion.SPM(options={"SEI": "ec reaction limited"}), "experiment": "from data", "objective variables": ["LLI [%]"], # "unified" is applied automatically. Override to fall back to # the legacy per-step model: # "simulation_kwargs": {"experiment_model_mode": "legacy"}, }, ), }, parameters={...}, ) ``` Only `CycleAgeing` sets this default — other simulation-backed objectives keep pybamm's usual `experiment_model_mode`. If you need the same behaviour on a different objective, pass `experiment_model_mode="unified"` in `simulation_kwargs` explicitly. For most optimisers, `SSE` is the safest choice — it has both a residual-array form and a scalar form, so it's compatible with every algorithm. Use `MSE` or `RMSE` when you need scale-independent reporting. Residual vs. canonical form, MLE interpretation. Putting objectives, parameters, and optimisers together. # Data Fitting Overview Source: https://docs.ionworks.com/build/parameterize/data-fitting/overview Estimate battery model parameters by fitting to experimental data with iws.DataFit. `iws.DataFit` describes a parameter fit: which experiments to compare against, which parameters are free, and how to search. The schema is submitted as one element of a pipeline. For the theory (cost functions, identifiability, multi-start), see the [Data Fitting Guide](/guide/data-fitting/overview). ## A minimal fit ```python theme={null} import pybamm import ionworks_schema as iws from ionworks import Ionworks # Known parameters (everything not fit) known = iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, ) # Objective: compare a current-driven SPMe simulation against measured voltage obj_1C = iws.objectives.CurrentDriven( data_input="file:examples/data/chen_synthetic_1C/time_series.csv", options={"model": pybamm.lithium_ion.SPMe()}, ) # Free parameters parameters = { "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", initial_value=2e-14, bounds=(1e-14, 1e-13), ), "Positive particle diffusivity [m2.s-1]": iws.Parameter( "Positive particle diffusivity [m2.s-1]", initial_value=2e-15, bounds=(1e-15, 1e-14), ), } fit = iws.DataFit( objectives={"test_1C": obj_1C}, parameters=parameters, cost=iws.costs.SSE(), optimizer=iws.optimizers.DifferentialEvolution(), ) pipeline = iws.Pipeline({"known": known, "fit": fit}) client = Ionworks() submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("fit") print(fit_result.parameter_values) ``` Configuration mistakes inside a `DataFit` (bad parameter names, malformed objectives, …) surface as `UserConfigurationError`. The job classifier maps these to a **Configuration error** in Studio so they're easy to distinguish from solver failures. ## Multiple objectives Pass multiple objectives to fit against several experiments simultaneously (e.g. discharge at different C-rates or temperatures): ```python theme={null} import pybamm fit = iws.DataFit( objectives={ "1C": iws.objectives.CurrentDriven( data_input="file:.../1C.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), "0.5C": iws.objectives.CurrentDriven( data_input="file:.../0.5C.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), }, parameters=parameters, ) ``` Each objective contributes to a single combined cost. ## Optimizers `iws.optimizers` exposes the optimisers available to `DataFit`. Pick the one that fits your problem: | Schema | Best for | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iws.optimizers.ScipyMinimize(method="L-BFGS-B")` | Smooth problems, fast local optimisation | | `iws.optimizers.ScipyLeastSquares()` | Residual-based least-squares; good with priors. Uses an analytic parameter Jacobian when the model supplies one. | | `iws.optimizers.ScipyLsqLinear()` | Bounded **linear** least-squares (single-solve BVLS/TRF). Use as the inner solver of a `Nested` fit when the inner residual is linear in its parameters. | | `iws.optimizers.DifferentialEvolution()` | Global, no gradients required | | `iws.optimizers.CMAES()` | Global, many local minima, well-tested defaults | | `iws.optimizers.PSO()` | Global, parallelisable population search | | `iws.optimizers.BayesianOptimization()` | Expensive evaluations, ≤ \~10 parameters, small budget | | `iws.optimizers.TuRBO()` | Expensive evaluations run in parallel batches; higher-dimensional problems | | `iws.optimizers.SOBER()` | Wide parallel batches using quadrature-style recombination | See [Objective Functions](/build/parameterize/data-fitting/objective-functions) for the cost-function options. The surrogate optimisers (`BayesianOptimization`, `TuRBO`, `SOBER`) are available with no extra installation required — their `torch`, `botorch`, and `gpytorch` dependencies are handled automatically when the fit runs on the platform. ### TuRBO for expensive parallel problems When each evaluation is expensive, `TuRBO` proposes a batch of candidates per round and adapts a trust region around the current best point. Worker count is decided by the platform when the fit runs, so set the warm-up (`n_initial`) to the batch size you want evaluated in the first round: ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives=objectives, parameters=parameters, optimizer=iws.optimizers.TuRBO( max_iterations=12, population_size=64, algorithm_options={"noise_floor": "low", "n_initial": 64}, ), ) ``` `DataFit` no longer takes `parallel`, `num_workers`, or `max_batch_size` (and `AskTellOptimizer` no longer takes `async_mode`). Parallelism is decided by the platform when the fit runs, not set in the config. Stored configs that still carry the removed fields are migrated automatically at parse time, so no action is required for existing saved fits. Useful `algorithm_options` keys for surrogate optimisers include `n_initial` (warm-up sample count), `noise_floor` (`"low"`, `"standard"`, or a `(lo, hi)` interval), and — for TuRBO — trust-region controls (`tr_length_init`, `tr_length_min`, `tr_length_max`, `tr_success_tolerance`, `tr_failure_tolerance`, `n_candidates`). ### Strict option validation `algorithm_options` is validated against the optimiser you chose. Unknown keys — including typos — are rejected at submission time rather than silently ignored, so misconfigured fits fail fast instead of running with default behaviour: ```python theme={null} # Raises a validation error: "noise_flor" is not a recognised TuRBO option. iws.optimizers.TuRBO(algorithm_options={"noise_flor": "low"}) # AskTellOptimizer is the low-level class behind CMAES(), PSO(), XNES(), etc.; # the named optimisers above are thin wrappers that set `method` for you. # Raises a validation error: BO options passed to a CMAES fit. iws.optimizers.AskTellOptimizer( method="CMAES", algorithm_options=iws.optimizers.BayesianOptimizationOptions(n_initial=32), ) ``` The same check applies whether you pass a raw dict or one of the typed wrappers (`CMAESOptions`, `PSOOptions`, `DEOptions`, `XNESOptions` — for the `XNES` optimizer, available via `iws.optimizers.XNES()` / `AskTellOptimizer(method="XNES")` — `BayesianOptimizationOptions`, `SOBEROptions`, `TuRBOOptions`). Prefer the typed wrappers for editor autocomplete and inline documentation of each option. The only exception is `CMAESOptions`, which remains a passthrough to pycma's own option surface. #### No SciPy-style kwargs on native optimizers Native ask/tell optimizers (`CMAES`, `DifferentialEvolution`, `PSO`, `XNES`, `BayesianOptimization`, `TuRBO`, `SOBER`, and the underlying `AskTellOptimizer`) also reject unknown top-level keyword arguments at construction. SciPy-style keys such as `maxiter`, `popsize`, `seed`, and `tol` are **not** accepted — they previously had no effect and now fail validation immediately, so misconfigured fits surface at construction rather than silently: ```python theme={null} # Raises a validation error — "Extra inputs are not permitted" for each unknown # key (maxiter, popsize, tol). iws.optimizers.DifferentialEvolution(maxiter=10, popsize=5, tol=1e-6) # Correct: use the documented ask/tell parameters (tol -> population_convergence_tol). iws.optimizers.DifferentialEvolution( max_iterations=10, population_size=5, population_convergence_tol=1e-6, ) # Algorithm-specific settings go in `algorithm_options`. iws.optimizers.CMAES(algorithm_options={"seed": 42}) ``` SciPy-style keys still belong on the SciPy passthrough optimizers (`ScipyMinimize`, `ScipyLeastSquares`, `ScipyDifferentialEvolution`), which forward them directly to the underlying SciPy call: ```python theme={null} # OK: ScipyDifferentialEvolution forwards maxiter/popsize/seed/tol to scipy.optimize. iws.optimizers.ScipyDifferentialEvolution(maxiter=10, popsize=5, seed=0) ``` In short: put iteration, population, and tolerance limits in the named ask/tell arguments (`max_iterations`, `population_size`, `population_convergence_tol`); put algorithm internals in `algorithm_options`; and keep SciPy keywords on the `Scipy*` optimizers. ### Strict objective validation `DataFit.objectives` (and `Validation.objectives`) validates each entry against a discriminated union keyed on the objective's `type`. Configuration mistakes are rejected at submission time with a clear `ValidationError`, instead of being silently ignored or surfacing later as an opaque runtime crash. Objective **instances** (e.g. `iws.objectives.CurrentDriven(...)`) keep working unchanged — both the positional and keyword constructors are preserved. The strict-validation rules apply when you pass raw config **dicts**, which is common for configs loaded from JSON or produced by `to_config()`: ```python theme={null} # OK — concrete type, all keys recognised. fit = iws.DataFit( objectives={ "1C": { "type": "CurrentDriven", "data": "file:.../1C.csv", "options": {"model": {"type": "SPMe"}}, }, }, parameters=parameters, ) # Also OK — the legacy `objective` alias and top-level `model` shorthand # are normalised into `type` and `options.model` before validation. fit = iws.DataFit( objectives={ "1C": { "objective": "CurrentDriven", "model": {"type": "SPMe"}, "data": "file:.../1C.csv", }, }, parameters=parameters, ) ``` The following are now rejected up-front: | Mistake | Example | Why it fails | | ---------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | | Unknown objective type | `{"type": "NotAnObjective"}` | Type isn't a member of the union. | | Missing discriminator | `{"electrode": "positive", "data": "x.csv"}` | No `type` (or legacy `objective`) to dispatch on. | | Unknown inner key | `{"type": "OCPHalfCell", "definitely_not_a_field": 1}` | Schemas use `extra="forbid"`; typos are caught early. | | Non-concrete type | A generic/base name instead of a specific objective | Only concrete objective types (e.g. `OCPHalfCell`) validate. | | Conflicting discriminators | `{"type": "OCPHalfCell", "objective": "CurrentDriven", ...}` | Raises `Conflicting objective discriminators` — fix one of the keys. | | Non-dict, non-instance value | `{"bad": "RMSE"}` / `{"bad": 123}` | Objective values must be a config dict or an objective instance. | In a raw config dict the data key is `data`, not `data_input`. The Python constructor exposes it as the `data_input` keyword argument, but `to_config()` serialises it to `data` — so hand-written dicts and JSON configs must use `data`. Passing `data_input` in a dict is now rejected as an unknown key. `DesignObjective` configs are not accepted on the `DataFit` runtime path: they run on the separate design-optimization pipeline. Passing a `DesignObjective` dict to `DataFit.from_schema` raises a `UserConfigurationError` pointing you at the design-optimization pipeline instead of crashing later. ## Multi-start For problems with multiple local minima, run several optimisations from different starting points: ```python theme={null} fit = iws.DataFit( objectives=objectives, parameters=parameters, multistarts=20, ) ``` The pipeline generates initial guesses (Latin Hypercube by default), runs them in parallel, and returns every result sorted by cost. ### Choosing the initial-guess sampler By default multi-start uses Latin Hypercube sampling, which spreads initial guesses evenly across the parameter bounds. Override the sampler with `initial_guess_sampler` when you want a different sampling scheme — for example, plain uniform sampling for a baseline comparison: ```python theme={null} fit = iws.DataFit( objectives=objectives, parameters=parameters, multistarts=20, initial_guess_sampler=iws.distribution_samplers.Uniform(), ) ``` Available samplers: | Sampler | Use for | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `iws.distribution_samplers.LatinHypercube()` | Default. Stratified samples that cover the parameter space more evenly than independent draws — recommended for most fits. | | `iws.distribution_samplers.Uniform()` | Independent uniform draws across the bounds. Useful as a baseline or when you want IID samples. | The sampler is validated against a discriminated union at submission time: passing an unknown sampler `type` or an unrecognised key raises a `ValidationError` immediately instead of failing later in the run. ## Nested (variable-projection) fits `iws.optimizers.Nested` is a bi-level optimizer: an **outer** optimizer searches over a chosen subset of parameters, and for every outer trial point an **inner** optimizer is run to optimality over the remaining parameters. This is the variable-projection formulation — the outer sees a concentrated objective $g(x_\text{outer}) = \min_{x_\text{inner}} f(x_\text{outer}, x_\text{inner})$ and the inner absorbs the parameters that are cheap to fit conditionally. Use `Nested` when: * One subset of parameters enters the model **linearly** (or near-linearly) and can be fit in closed form with a bounded least-squares inner solve, while the rest are nonlinear. * The full joint search is ill-conditioned or slow, but the reduced outer problem is well-behaved. * You want a global outer search (e.g. CMA-ES or `Nelder-Mead`) with a fast local inner refinement at every point. ```python theme={null} import ionworks_schema as iws fit = iws.DataFit( objectives=objectives, parameters=parameters, optimizer=iws.optimizers.Nested( parameters=["Negative particle diffusivity [m2.s-1]"], optimizer=iws.optimizers.ScipyMinimize(method="Nelder-Mead"), inner=iws.optimizers.ScipyLeastSquares(), ), ) ``` `parameters` lists the fit-parameter names this level's outer optimizer controls; every other free parameter in the `DataFit` is delegated to `inner`. `inner` can itself be a `Nested` for deeper nesting. The concentrated objective can be non-smooth where the inner optimum is non-unique, so derivative-free or finite-difference outer optimizers (`Nelder-Mead`, `CMAES`, `DifferentialEvolution`) are recommended for the outer slot. Samplers are rejected at construction — both slots must be optimizers. ### Bounded linear inner solves with `ScipyLsqLinear` When the inner parameters enter the residual **linearly** (for example, a linear combination of basis functions with bounded coefficients), pair the `Nested` estimator with `iws.optimizers.ScipyLsqLinear` as the inner solver. It reaches the bounded optimum in a single `scipy.optimize.lsq_linear` call rather than iterating with Gauss-Newton, and warm-starts its active set across successive outer evaluations so the concentrated objective stays smooth: ```python theme={null} optimizer=iws.optimizers.Nested( parameters=["k_nonlinear"], optimizer=iws.optimizers.CMAES(), inner=iws.optimizers.ScipyLsqLinear( method="bvls", # or "trf" warm_start=True, # reuse the previous active set (default) ), ) ``` Use `ScipyLsqLinear` only when the residual really is linear in the inner parameters; for nonlinear inner residuals, keep `ScipyLeastSquares`. ### Analytic parameter Jacobian When the outer or inner slot is a least-squares optimizer (`ScipyLeastSquares` or `ScipyLsqLinear`), the pipeline supplies an **analytic residual Jacobian** $J(x) = \partial r / \partial x$ built from the model's parameter sensitivities. That replaces SciPy's finite-difference Jacobian, so each iteration takes one solve plus one sensitivity evaluation instead of $O(n_\text{params})$ solves. In practice this makes gradient-based least-squares fits substantially faster and more robust to noisy finite-difference steps, especially inside a `Nested` variable-projection loop. No configuration is required — the analytic Jacobian is used automatically when the objective and model support it, and the fit transparently falls back to finite differences otherwise. Look for `using analytic residual Jacobian` in the fit logs to confirm it's active. ## Runtime options `iws.DataFit` accepts `options`, which tunes *how* a fit executes rather than what it fits. Every key is optional. | Key | Default | Description | | -------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `seed` | engine picks one from the clock | Random seed, for reproducible multi-start initial guesses and stochastic optimisers. Must be in `[0, 2**32 - 1]` — the range numpy accepts. | | `low_memory` | engine decides | Append a log entry only when the cost improves the best-so-far by ≥0.1%. Left unset, the engine enables it for deterministic optimisers and disables it for probabilistic ones. | | `max_iterations` | `None` | Per-job iteration cap. Must be positive. Only takes effect when the model's `convert_to_format` is `"casadi"`. | | `maxtime` | `None` | Per-job wall-time budget in seconds. Must be positive and finite. With multi-start the total may exceed this, since many jobs run. Only takes effect when `convert_to_format` is `"casadi"`. | | `validate` | `True` | Before the fit starts, check that every fit parameter is actually used by at least one objective's model. Catches a misspelt or orphaned parameter name up front, rather than after a fit that could never have moved it. | | `skip_objective_callbacks` | `False` | Skip the per-objective callbacks that simulate the model at the initial guess and at the fitted parameters. Faster, but leaves the initial/final fit results unpopulated. See the note below for cluster behaviour. | Pass a dict: ```python theme={null} fit = iws.DataFit( objectives=objectives, parameters=parameters, options={"seed": 42, "low_memory": True, "maxtime": 600}, ) ``` or `iws.DataFitOptions`, which gives you editor autocomplete and inline documentation of each field: ```python theme={null} fit = iws.DataFit( objectives=objectives, parameters=parameters, options=iws.DataFitOptions(seed=42, low_memory=True, maxtime=600), ) ``` Only the keys you set are sent. `iws.DataFitOptions().to_config()` is `{}`, so leaving a field alone defers to the engine's own default instead of freezing today's default into a stored config. ### Unknown option keys are rejected Options are validated when you build the config, not part-way through the job. A misplaced or misspelt key fails immediately: ```python theme={null} # Raises a validation error: "Extra inputs are not permitted" for multistarts. # multistarts is a DataFit field, not a runtime option. iws.DataFit(objectives=objectives, parameters=parameters, options={"multistarts": 5}) # Correct — it belongs one level up. iws.DataFit(objectives=objectives, parameters=parameters, multistarts=5) ``` The check applies whether you pass a raw dict or `iws.DataFitOptions`, and the value constraints are enforced too — `seed=-1`, `max_iterations=0`, or a non-finite `maxtime` all fail at construction. This mirrors the [strict validation on optimiser options](#strict-option-validation): the same principle, one level up the config. Pipelines submitted to the Ionworks cluster enable `skip_objective_callbacks` by default to reduce simulation cost. Set it explicitly to `False` in `options` if you need the initial- and final-fit simulation results returned with the run. ## Objective-level parallelism `DataFit` exposes `objective_parallelism` as a top-level field — a debugging escape hatch for objective-level scheduling. It is a direct `DataFit` argument, **not** a `Runtime options` key: | Value | Behaviour | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `"auto"` (default) | The execution engine decides. Multiple non-trivial objectives are flattened and evaluated in parallel; single-objective fits stay sequential. | | `"on"` | Force objective-level parallelism. Useful when the auto heuristic underestimates the per-objective cost. | | `"off"` | Force sequential objective evaluation. Useful for reproducing a single-threaded baseline or when debugging an objective in isolation. | ```python theme={null} fit = iws.DataFit( objectives=objectives, parameters=parameters, objective_parallelism="off", # force sequential evaluation while debugging ) ``` Leave it at `"auto"` unless you have a specific reason to override. The setting only affects scheduling — it does not change the cost the optimizer sees. ## Troubleshooting model failures If an objective's model can't be set up or evaluated during a fit — for example because the parameter set is incomplete, a custom model can't be discretised, or a state-of-charge initialisation fails — the pipeline raises a `ModelError` that names the offending objective and the underlying cause: ```text theme={null} Failed to set up the model for objective 'test_1C': Parameter 'Negative electrode thickness [m]' not found. ``` `ModelError` is distinct from a generic `CONFIGURATION_ERROR`: the failed job is tagged with the `MODEL_ERROR` code so the message can be shown to you directly without being routed through internal error monitoring. The fix is almost always to your fit configuration — supply the missing parameter, widen unrealistic bounds, or adjust the model options on the objective — rather than something to report. Already-clear errors pass through unchanged: solver-side numerical failures still surface as `pybamm.SolverError` / `SOLVER_ERROR`, and parameter-lookup failures still surface as `ParameterNotFoundError`. Those continue to be handled by the optimizer's normal fallback (NaN cost, skipped step) when they occur inside an evaluation, so a single bad iteration won't kill the whole fit. ### Every evaluation returned a non-finite cost If **every** cost evaluation across the whole fit is non-finite (NaN or ±infinity), the optimizer has no usable signal to move away from the initial guess. In that case the pipeline fails the fit explicitly instead of silently returning the initial parameters as if the fit had succeeded. The failure surfaces as: ```text theme={null} No fit was found: every evaluation failed (minimum cost is inf). Likely cause: ... ``` The `Likely cause:` clause is filled in with one of three diagnoses: * **The target data contains non-finite (inf/NaN) values**, so the cost can never be finite. The offending variables are named in the message. This usually means an invalid problem setup — for example a resistance or overpotential computed on a zero-current rest step, or inf/NaN in the input data. Remove or correct those points. * **A model evaluation raised an exception** (the exception type and message are included). Fix the underlying model, parameter, or bounds problem it reports. * **The model produced non-finite output for every parameter set** even though the target data is finite. Check that the model gives finite outputs at the initial parameter values and across the bounds (no divide-by-zero or invalid parameter combinations). If every evaluation returned the *identical* cost, the message says so — either the fit parameters don't affect the model outputs, or every solve fell back to the same failure cost. The fix is almost always to your fit configuration: tighten the bounds around a point where the model is known to solve, verify the objective data aligns with the experiment, or run a single simulation at the initial guess to confirm it produces finite output before submitting the fit. A fit where at least one evaluation is finite is unaffected — the optimizer's NaN fallback still handles occasional bad iterations. ## Retrieving results ```python theme={null} client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("fit") fit_result.parameter_values["Positive particle diffusivity [m2.s-1]"] fit_result.cost ``` `result.element(name)` returns a typed result object for that element, and `result.results` maps every element name to one. Each carries the fitted `parameter_values`, the final `cost`, and any logged trajectories, and can plot itself: ```python theme={null} fit_result.plot_fit_results() # model against the target data fit_result.plot_trace() # cost and parameters over the optimizer's run ``` Plotting needs matplotlib, which is not installed by default: `pip install 'ionworks-schema[plot]'`. For the numbers behind the figures, read the model-vs-data channels: ```python theme={null} fit_result.overlay # decimated, the data the plots draw fit_result.series # {objective: {source: {channel: [values]}}}, full resolution ``` Both are fetched the first time you read them and then cached. Use `.overlay` for figures and `.series` when you want every sample to save or analyse. The raw payloads are unchanged and still available as `result.result` and `result.element_results` if you need them. See [`packages/ionworks-api/examples/pipeline/datafit.py`](https://github.com/ionworks/ionworks-api/tree/main/examples/pipeline/datafit.py) for an end-to-end example. Cost-function math, identifiability, multi-start strategy. Pick the right cost for your data shape. Stabilise fits with Gaussian priors. Quantify which parameters the fit actually constrains. # Regularization Source: https://docs.ionworks.com/build/parameterize/data-fitting/regularization Stabilise parameter fits with Gaussian priors using iws.priors and iws.stats distributions. Regularization in `iws.DataFit` is expressed as **priors** on the fit parameters. Each prior pairs a parameter name with a distribution from `iws.stats`. For why regularization is needed and how to choose prior strengths, see the [Regularization Guide](/guide/data-fitting/regularization). ## Distributions | Schema | Use for | | ---------------------------------------- | ----------------------------------------------------------------------------------------- | | `iws.stats.Normal(mean=..., std=...)` | Gaussian prior, additive scale | | `iws.stats.LogNormal(mean=..., std=...)` | Strictly-positive parameter spanning orders of magnitude (e.g. solid-phase diffusivities) | | `iws.stats.Uniform(lb=..., ub=...)` | Hard support; equivalent to bounds with constant density | | `iws.stats.MultivariateNormal(...)` | Correlated priors across multiple parameters | ## A regularized fit ```python theme={null} import pybamm import ionworks_schema as iws from ionworks import Ionworks parameters = { "Positive particle diffusivity [m2.s-1]": iws.Parameter( "Positive particle diffusivity [m2.s-1]", initial_value=1e-14, bounds=(1e-16, 1e-12), ), "Negative particle diffusivity [m2.s-1]": iws.Parameter( "Negative particle diffusivity [m2.s-1]", initial_value=3e-14, bounds=(1e-16, 1e-12), ), } priors = { "Positive particle diffusivity [m2.s-1]": iws.priors.Prior( "Positive particle diffusivity [m2.s-1]", iws.stats.LogNormal(mean=-32.2, std=1.0), # log-mean, log-std ), "Negative particle diffusivity [m2.s-1]": iws.priors.Prior( "Negative particle diffusivity [m2.s-1]", iws.stats.LogNormal(mean=-31.1, std=1.0), ), } fit = iws.DataFit( objectives={ "1C": iws.objectives.CurrentDriven( data_input="file:.../1C.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), }, parameters=parameters, priors=priors, optimizer=iws.optimizers.ScipyLeastSquares(), ) pipeline = iws.Pipeline({"fit": fit}) client = Ionworks() submission = client.pipeline.create(pipeline) ``` The `priors` dict adds a regularization term to the cost function so deviations from the prior mean are penalised in proportion to the prior's inverse variance. ## Attaching priors via `Parameter` Priors can also be attached directly to a `Parameter` rather than passed as a separate dict — useful when the prior is intrinsic to that parameter: ```python theme={null} diffusivity = iws.Parameter( "Positive particle diffusivity [m2.s-1]", initial_value=1e-14, bounds=(1e-16, 1e-12), prior=iws.stats.LogNormal(mean=-32.2, std=1.0), ) ``` ## Dict-form priors Schema configs can also be written as plain dicts — useful when a config is loaded from JSON or YAML. Two equivalent forms are accepted in the `priors` mapping: ```python theme={null} # Flat form: distribution name as a string, params inline priors = { "Positive particle diffusivity [m2.s-1]": { "distribution": "LogNormal", "mean": -32.2, "std": 1.0, }, } # Nested form: distribution as its own dict priors = { "Positive particle diffusivity [m2.s-1]": { "distribution": {"distribution": "LogNormal", "mean": -32.2, "std": 1.0}, "regularizer_weight": 2.0, }, } ``` The mapping key is the authoritative parameter name. Any `name` embedded inside the prior dict is ignored — including when the parameter itself is literally named `"distribution"`. ## Strict validation Priors, distributions, and samplers are validated against discriminated unions at submission time. This catches typos and stale configs before a run starts, rather than letting them surface as opaque runtime crashes. Mistakes that are now rejected with a `ValidationError`: * An unknown distribution name (e.g. `{"distribution": "Guassian", ...}`). * A stray or misspelled key in a distribution, prior, or sampler config. * A `type` discriminator that doesn't match the field (e.g. `{"type": "Penalty", ...}` placed under `priors`). * A bare scalar or list passed where a prior, distribution, or sampler is expected. The legacy `type` alias is still accepted in place of the `distribution` discriminator **inside a distribution config** — e.g. the nested form's inner dict `{"type": "Normal", "mean": 3.0, "std": 0.2}` resolves to a `Normal` — so existing serialized configs round-trip unchanged. Note that `type` only aliases `distribution` at the distribution level: at the top level of a prior mapping, `type` is the prior discriminator (it must be `"Prior"`), so a flat-form prior must name its distribution with `distribution`, not `type`. ## Why `LogNormal` for diffusivities Solid-phase diffusivities span many orders of magnitude (often $10^{-16}$ to $10^{-10}$ m²/s). A `Normal` prior on the raw value is hard to specify — mean ± std doesn't reflect order-of-magnitude uncertainty. A `LogNormal` prior treats the parameter on a log scale, so "mean ± 1 std" corresponds to a factor of $e$ — much more natural. The `mean=-32.2` in the example above is the natural log of $\sim 10^{-14}$, so the prior is centred on a typical particle diffusivity. Ridge regression, MAP estimation, bias–variance tradeoff. Putting priors together with objectives and optimisers. # Direct Entries Source: https://docs.ionworks.com/build/parameterize/direct-entries Drop coherent literature parameter sets into a pipeline with ionworks-schema direct entries. A direct entry populates parameter values without doing any calculation or fitting. There are two shapes: * **`iws.direct_entries.DirectEntry`** — a flat dict of values you supply yourself. * **`iws.direct_entries.*`** function schemas (e.g. `landesfeind_electrolyte`) — pre-built parameterisations from the literature, exposed under snake\_case names. For the physics behind the electrolyte parameterisations and when to pick each one, see the [Electrolyte direct entries Guide](/guide/pipelines/direct-entries/electrolyte) and [Electrolyte transport](/guide/modeling/electrolyte-transport). ## Custom DirectEntry ```python theme={null} import ionworks_schema as iws known = iws.direct_entries.DirectEntry( parameters={ "Ambient temperature [K]": 298.15, "Nominal cell capacity [A.h]": 3.0, "Lower voltage cut-off [V]": 2.5, "Upper voltage cut-off [V]": 4.2, }, source="manufacturer datasheet", ) pipeline = iws.Pipeline({"known": known}) ``` `parameters` accepts floats, arrays, and pybamm-serialisable symbols. ### Callable parameters via `pybamm.ParameterValues` For parameters that are callables — for example, concentration- or temperature-dependent interpolants — wrap them in a `pybamm.ParameterValues` and pass that. Its serialisation converts each callable into the symbolic form the server reconstructs on the other side. A raw `dict` containing callables is **not** auto-serialised; you must wrap it explicitly. ```python theme={null} import numpy as np import pybamm import ionworks_schema as iws x = np.linspace(100.0, 3000.0, 6) pv = pybamm.ParameterValues( { "Electrolyte conductivity [S.m-1]": lambda c_e, T: pybamm.Interpolant( x, 0.1 + 1e-3 * x, c_e, name="conductivity" ), "Electrode height [m]": 0.1, } ) known = iws.direct_entries.DirectEntry(parameters=pv, source="custom fit") ``` The interpolant lambda's signature must be exactly `(c_e, T)` (or the relevant pybamm input variables) — capture any extra inputs (lookup arrays, names) via closure rather than default kwargs, since defaults are inferred as function inputs during serialisation and break reconstruction. ## Electrolyte direct entries | Schema | What it sets | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `iws.direct_entries.constant_electrolyte(c_e=...)` | Initial salt concentration only | | `iws.direct_entries.nyman_electrolyte(c_e=...)` | $\kappa(c_e)$, $D_e(c_e)$, $\chi$, $t_+^0$ from Nyman et al. 2008 (isothermal) | | `iws.direct_entries.landesfeind_electrolyte(c_e=..., system=...)` | Full $T$- and $c_e$-dependent set from Landesfeind & Gasteiger 2019 | | `iws.direct_entries.arrhenius_electrolyte_diffusivity()` | Wraps a reference $D_e(c_e)$ in an Arrhenius temperature factor | | `iws.direct_entries.arrhenius_electrolyte_conductivity()` | Wraps a reference $\kappa(c_e)$ in an Arrhenius temperature factor | ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks electrolyte = iws.direct_entries.landesfeind_electrolyte( c_e=1000.0, system="EC:EMC (3:7)", ) pipeline = iws.Pipeline({"electrolyte": electrolyte}) client = Ionworks() submission = client.pipeline.create(pipeline) ``` `system` must be one of `"EC:DMC (1:1)"`, `"EC:EMC (3:7)"`, or `"EMC:FEC (19:1)"`. ## Building electrolyte transport from a material dataset If you have measured electrolyte transport properties (conductivity, diffusivity, transference number, thermodynamic factor) versus concentration stored as a [material property dataset](/data/materials), `client.electrolyte.transport_from_dataset()` turns the dataset into a `pybamm.ParameterValues` of concentration-dependent functions that you can drop straight into a `DirectEntry`. Each property is represented in one of two ways, chosen per-parameter: * `"interpolant"` (default) — a tabulated `pybamm.Interpolant` of the measured points with linear extrapolation. * `"landesfeind"` — the isothermal [Landesfeind & Gasteiger (2019)](https://doi.org/10.1149/2.0571912jes) functional form for that property, fitted to the measured points. Unlike a tabulated interpolant, the fitted conductivity and diffusivity forms stay positive and finite below the lowest measured concentration, which keeps high-rate DFN solves stable when the electrolyte depletes near an electrode. ```python theme={null} from ionworks import Ionworks import ionworks_schema as iws client = Ionworks() params = client.electrolyte.transport_from_dataset( "dataset-uuid", forms={ "Electrolyte conductivity [S.m-1]": "landesfeind", "Electrolyte diffusivity [m2.s-1]": "landesfeind", "Cation transference number": "interpolant", }, ) electrolyte = iws.direct_entries.DirectEntry(parameters=params, source="in-house measurements") pipeline = iws.Pipeline({"electrolyte": electrolyte}) submission = client.pipeline.create(pipeline) ``` ### Arguments | Argument | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dataset_id` | UUID of the [material property dataset](/data/materials) holding the transport properties versus electrolyte concentration. | | `forms` | Optional mapping from pybamm parameter name to `"interpolant"` or `"landesfeind"`. Parameters not listed default to `"interpolant"`. | | `columns` | Optional mapping from pybamm parameter name to dataset column name. Defaults to `{"Electrolyte conductivity [S.m-1]": "Electrolyte conductivity", "Electrolyte diffusivity [m2.s-1]": "Electrolyte diffusivity", "Cation transference number": "Transference number"}`. Add a thermodynamic-factor entry here if your dataset includes it, e.g. `{"Thermodynamic factor": "My TDF column"}` (the key is the pybamm parameter name `Thermodynamic factor`, the value is your dataset's column name). | | `concentration_column` | Name of the electrolyte-concentration column (in mol.m-3) in the dataset. Defaults to `Electrolyte concentration`. | The `"landesfeind"` form is available for `Electrolyte conductivity [S.m-1]`, `Electrolyte diffusivity [m2.s-1]`, `Cation transference number`, and `Thermodynamic factor`. The dataset is treated as isothermal — each fitted form depends only on concentration. ## Piecewise interpolant direct entries For SOC- or temperature-dependent parameters, use `iws.direct_entries.PiecewiseInterpolation1D` or `PiecewiseInterpolation2D`. See [Calculations → Piecewise](/build/parameterize/calculations/piecewise). ## Fitting coefficients from a direct entry The transport-property coefficients inside `landesfeind_electrolyte` are exposed as named parameters precisely so they can be overridden as fit unknowns. Put the entry into a pipeline and reference the same parameter names in a downstream [`DataFit`](/build/parameterize/data-fitting/overview) — the published values act as the base and the optimizer searches over the overridden ones. Physics behind the four transport properties. How direct entries fit into the wider pipeline. # ECM parameterization Source: https://docs.ionworks.com/build/parameterize/ecm Fit OCV, R0, and RC-pair parameters of an equivalent circuit model from experimental cycling data, in-browser The **ECM Parameterization** tool fits an equivalent circuit model (ECM) to experimental battery cycling data directly in the browser. Fit a measurement that you've already uploaded to a [project](/core-concepts/projects-studies), upload your own file, or use one of the built-in example datasets to extract cell capacity, OCV, R0, and RC-pair parameters as functions of state of charge (SOC). There are two ways to launch the tool: * **Inside a project** — open the **ECM Fitting** page from the project sidebar. You can pick a measurement that's already attached to one of the project's cells and the result is saved back to the project. * **Standalone demo** — visit [studio.ionworks.com/ecm-demo](https://studio.ionworks.com/ecm-demo) directly. No login is required, and you can experiment with example datasets or upload a one-off file without saving anything. ## How it works The tool fits a circuit consisting of an open-circuit voltage (OCV) source, a series resistance (R0), and one or more RC pairs to time-series voltage and current data. The fitting process extracts parameters as smooth functions of SOC, and co-optimises the cell's usable capacity together with the RC parameters using the fitted OCV(SOC) curve as a constraint. The circuit structure looks like this: ``` ┌───[ R0 ]───┬───[ R_rc1 ]───┬───[ R_rc2 ]───┐ │ │ │ │ OCV [ C_rc1 ] [ C_rc2 ] V_terminal │ │ │ │ └─────────────┴───────────────┴───────────────┘ ``` Each RC pair captures a different timescale of dynamic polarization behavior. More RC pairs produce a more accurate fit but also increase model complexity. ### Capacity co-optimisation Cycling data often doesn't reflect the cell's exact usable capacity — measurements are taken at different temperatures, C-rates, and ages, and small errors in the assumed capacity bias every SOC-dependent parameter that follows. To avoid this, the fit co-optimises the cell's capacity alongside the RC parameters, using the OCV(SOC) curve as a self-consistency constraint: SOC at any time is computed from the integrated current divided by the fitted capacity, and the OCV at that SOC must agree with the rest-voltage segments of the data. The fitted capacity is returned in the fit response, so you can use it directly when building a [parameterized model](/build/parameterized-models) without having to estimate cell capacity separately. ## Fitting a measurement in a project Use this workflow when you want the fit to be associated with a specific [project](/core-concepts/projects-studies) and [cell](/core-concepts/cells), and to use a measurement you've already [uploaded](/data/uploading). Open the project, then click **ECM Fitting** in the sidebar. The page lists the cells in the project and the measurements attached to each one. Select the cell measurements you want to fit. A preview plot of the voltage and current traces is shown so you can confirm it's the right data before running the fit. Only `time_series` measurements with voltage, current, and time data can be fit. Properties and file-type measurements aren't shown in the picker. You can select **multiple measurements** to fit them jointly as a single ECM. This is useful when each measurement covers a different part of the operating window (for example, separate pulse trains at different SOCs or on different cell instances). The fit treats each measurement as its own segment and shares a single set of SOC-dependent parameters across all of them. Set the number of RC pairs (0–5) and toggle **Fit OCV** as described in [Configure the fit](#using-the-tool). When you select two or more measurements, you must enter an **initial SOC (0–1) for each measurement** in the configuration card. This tells the fitter where each segment starts on the SOC axis so it can stitch them together. Capacity remains a single value shared across all selected measurements (leave blank to estimate from the data). For a single measurement, the initial SOC field stays optional and is estimated automatically when left blank. Click **Parameterize ECM** to start. When the fit completes, review the results and click **Save** to attach the fitted parameter set to the project's cell. Saved fits appear in the cell's measurement history and can be used as a starting point when [creating a parameterized model](/build/parameterized-models#creating-a-new-parameterized-model). ## Using the tool The standalone demo at [studio.ionworks.com/ecm-demo](https://studio.ionworks.com/ecm-demo) — and the fit configuration step inside a project — share the same controls. Choose from built-in example datasets or upload your own cycling data file. (Inside a project, you instead pick a measurement attached to one of the project's cells, as described above.) **Built-in examples** include cells from published literature (Chen 2020, Ecker 2015, Prada 2013, and others) as well as drive-cycle profiles (UDDS, mixed current). Each example shows a recommended number of RC pairs. **Uploaded files** are automatically detected and parsed. The tool supports common cycler formats including CSV, Excel, and formats from BaSyTec, Maccor, and Biologic. Your file must contain time, voltage, and current columns. If an `Open-circuit voltage [V]` column is present, you can use it directly instead of fitting OCV. Set the number of **RC pairs** (0–5). More RC pairs capture faster dynamics but increase complexity. The recommended value depends on your data — example datasets show a suggested count. Toggle **Fit OCV** on or off. When your data includes a measured OCV column, you can disable OCV fitting to use the provided values directly and only fit R0 and RC parameters. After fitting, you see: * **Model vs. data** voltage comparison plot and RMSE * **OCV(SOC)** and **R0(SOC)** parameter curves * **R\_rc(SOC)**, **C\_rc(SOC)**, and **τ\_rc(SOC)** curves for each RC pair The fitted cell capacity, co-optimised from the OCV(SOC) curve, is returned in the fit response. Full RC-pair parameters require ECM results access to be enabled for your organization. Contact [info@ionworks.com](mailto:info@ionworks.com) to request access. ## Downloading results as CSV After a fit completes, click the **Download CSV** button in the results header to export the fitted parameters. The CSV contains 200 interpolated SOC points with these columns: | Column | Description | | --------------- | ------------------------------ | | `SOC` | State of charge (0 to 1) | | `OCV [V]` | Open-circuit voltage | | `R0 [mOhm]` | Series resistance in milliohms | | `R_rc_N [mOhm]` | RC pair N resistance | | `C_rc_N [F]` | RC pair N capacitance | | `tau_N [s]` | RC pair N time constant | Columns for each fitted RC pair follow this pattern (e.g. `R_rc_1`, `R_rc_2`, …). RC-pair columns are included only when ECM results access is enabled for your organization. Otherwise the CSV contains SOC, OCV, and R0 only. Contact [info@ionworks.com](mailto:info@ionworks.com) to request access. ## Data requirements Your cycling data must include: * **Time \[s]** — time in seconds * **Voltage \[V]** — terminal voltage * **Current \[A]** — applied current Optionally, include **Open-circuit voltage \[V]** to skip OCV fitting and use measured OCV directly. The tool uses [ionworksdata](https://data.docs.ionworks.com/) for format detection, so data exported from common cyclers is usually recognized automatically. ## Fitting from Python The same fits available in the UI can be submitted programmatically through `client.ecm` in the [Python API client](/api-client). Authenticated fits run as **background jobs** — each `fit_*` call returns immediately with a job handle, and `wait_for_completion` blocks until the result is ready (typically 10–60 s). Three input modes are supported: | Method | Source | Auth | Returns | | --------------------------------------- | ----------------------------------- | -------- | -------------------------- | | `client.ecm.fit_from_example(...)` | Built-in demo dataset | optional | `FitResults` (synchronous) | | `client.ecm.fit_from_file(...)` | Local file upload | required | `EcmFitJob` (async) | | `client.ecm.fit_from_measurements(...)` | Measurements stored in the platform | required | `EcmFitJob` (async) | ### Fit from stored measurements ```python theme={null} from ionworks import Ionworks client = Ionworks() fit_job = client.ecm.fit_from_measurements({ "measurements": [ {"id": "meas-id-1"}, {"id": "meas-id-2", "start_step": 5, "end_step": 50, "initial_soc": 0.95}, ], "ecm_options": { "num_rcs": 2, # 0–5 RC pairs (default 2) "fit_ocv": True, # set False when the data already has an OCV column }, }) result = client.ecm.wait_for_completion(fit_job, timeout=300) print(f"RMSE: {result.rmse_mV:.2f} mV") ``` `result` is a `FitResults` model with `time`, `data_voltage`, `model_voltage` traces, `soc`/`ocv`/`r0` parameter grids, and `rc_pairs[i].r/.c/.tau` curves for each RC pair. ### Fit from a local file Accepts CSV, parquet, and any cycler format that `ionworksdata` can detect: ```python theme={null} fit_job = client.ecm.fit_from_file( "data/pulse_test.csv", num_rcs=2, fit_ocv=True, initial_soc=0.95, # optional capacity=4.85, # optional, in Ah ) result = client.ecm.wait_for_completion(fit_job) ``` To preview a file before fitting (auto-detect format, return its time-series without running a fit), call `client.ecm.detect_and_read(file)`. ### Multi-measurement fits with per-segment SOC seeds When you fit multiple measurements jointly, each segment can carry its own `initial_soc` so the fitter starts each segment from the correct state of charge: ```python theme={null} fit_job = client.ecm.fit_from_measurements({ "measurements": [ {"id": "meas-fully-charged", "initial_soc": 1.00}, {"id": "meas-partial-discharge", "initial_soc": 0.80}, ], "ecm_options": {"num_rcs": 2, "capacity": 4.85}, }) ``` When `initial_soc` is omitted and an `ocv_soc_curve` is provided in `ecm_options`, the service auto-seeds `soc0` by inverting `V[s] = OCV(soc0) − I[s]·R0(soc0)` after a warm-up fit. Without a curve, single-measurement runs fall back to coulomb-counting. ### Per-measurement capacity When each measurement was recorded on a cell with a different known capacity — e.g. measurements taken at different ages, or on different physical cells you're fitting jointly — attach the capacity directly to each measurement dict: ```python theme={null} fit_job = client.ecm.fit_from_measurements({ "measurements": [ {"id": "meas-fresh-cell", "initial_soc": 1.00, "capacity": 4.85}, {"id": "meas-aged-cell", "initial_soc": 1.00, "capacity": 4.50}, ], "ecm_options": {"num_rcs": 2}, }) ``` Rules: * Per-measurement `capacity` is **all-or-none** — either every measurement supplies one, or none of them do. Mixed configurations are rejected at submission time. * Each capacity must be `> 0` (Ah). * When per-measurement capacities are provided, the shared `ecm_options.capacity` is ignored. When they're not, the shared value applies to every segment (or capacity is estimated / fit against `bounds_capacity` when the shared value is also omitted). * The returned `FitResults.capacity_Ah` is a list with one entry per measurement segment. When a single cell-wide capacity was used (estimated, fitted, or supplied via `ecm_options.capacity`), that value is repeated across segments so the shape stays consistent. Fitting capacity (via `bounds_capacity`) still produces a single cell-wide value shared across segments. Per-measurement `capacity` is for cases where you already **know** each segment's capacity and want to hold each one fixed. ### Smoothness regularization `ecm_options.regularization` applies a Gaussian smoothness prior to the R0 / RC parameter curves (never to OCV). Increase it to damp oscillations in the fitted SOC-dependent parameters when your data doesn't tightly constrain them — for example, noisy pulse data or short traces that only cover a narrow SOC window. ```python theme={null} fit_job = client.ecm.fit_from_measurements({ "measurements": [{"id": "meas-id-1"}], "ecm_options": { "num_rcs": 2, "regularization": 1.0, # 0 disables (default); larger = smoother curves }, }) ``` For `regularization > 0` the value maps internally to `scale = 5 / regularization`, so `1.0` is a modest prior and larger values apply a stronger smoothness penalty. `0.0` (the default) is special-cased to disable the prior entirely — no smoothness penalty is added and the `scale = 5 / regularization` formula is not evaluated (so there is no division by zero). The same option is available on `fit_from_file(..., regularization=...)`. ### Fitting capacity from a known OCV(SoC) curve If you have an OCV curve from a separate slow-rate characterisation, pass it via `ecm_options.ocv_soc_curve` to skip OCV fitting and (optionally) co-optimise capacity within explicit bounds: ```python theme={null} fit_job = client.ecm.fit_from_measurements({ "measurements": [{"id": "meas-id-1"}], "ecm_options": { "num_rcs": 2, "ocv_soc_curve": { "soc": [0.0, 0.1, 0.2, 0.5, 0.8, 1.0], "ocv": [3.0, 3.2, 3.4, 3.7, 4.0, 4.2], }, "bounds_capacity": {"lo": 4.0, "hi": 6.0}, }, }) ``` Constraints validated locally before the request hits the wire: * `ocv_soc_curve.soc` must be strictly increasing and lie in `[0, 1]`. * `ocv_soc_curve.soc` and `.ocv` must have the same length (≥ 2). * `bounds_capacity` is only consulted when `capacity` is `None`; it requires `hi > lo`. * Mutually exclusive with input data that already carries an `Open-circuit voltage [V]` column — pass one or the other. ### Tuning knot resolution For challenging traces (long relaxations, multiple time scales), bump the SOC-knot resolution via `ecm_options`: ```python theme={null} "ecm_options": { "num_rcs": 3, "num_knots": 21, # total SOC knots for RC-pair parameters "num_knots_r0": 7, # SOC knots for R0(SoC) "knot_schedule": [3, 5, 9, 21], # multi-resolution refinement schedule "clamp_boundary_knots": True, # default; relax for symbolic R_rc=alpha/beta "clamp_max_ratio": 10.0, } ``` `knot_schedule` must be a strictly increasing list of positive ints ending at `num_knots`. Defaults are auto-derived when omitted. ### Save a fit as a Parameterized Model Once a fit completes, persist it inside a project so it can be used in simulations: ```python theme={null} saved = client.ecm.save_to_project( name="ECM 2RC — pulse test", cell_spec_id="cell-spec-id", fit_results=result, description="Fitted from May 2026 pulse test", ) print(saved.parameterized_model_id) ``` The returned `parameterized_model_id` can be used as the `parameterized_model` in `client.simulation.protocol(...)`. See [Parameterized Models](/build/parameterized-models) for more. ### Fit a built-in example without auth `fit_from_example` is rate-limited (60/min) and synchronous — no job polling needed: ```python theme={null} examples = client.ecm.list_examples() result = client.ecm.fit_from_example(examples[0]["id"], num_rcs=2) ``` RC-pair parameters are only included for authenticated callers with ECM results access enabled. ## Validating on held-out data Once you have a fitted ECM, use `client.ecm.validate(...)` from the [Python API client](/api-client) to check how well it reproduces a **held-out** load case — a measurement, rate, or drive cycle the fit did *not* see. The call re-simulates the fitted model forward on the held-out current trace using the same engine the fit uses internally and returns aligned model-vs-data traces plus error metrics. The call is **synchronous** (no job, no [pipeline](/build/parameterize/overview)) and is the right tool for ECM held-out validation — don't route an ECM through a validation pipeline for this. ### When to use it * After a fit, to gate whether the model is accurate enough for the application. * To compare a saved parameterized model against a new measurement at a different rate or drive cycle. * To produce a model-vs-data overlay and residual plot for a report. ### Inputs Provide exactly one **held-out source** and exactly one **model source**: | Argument | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | `measurement_id` | Held-out cell measurement (not used in the fit). Mutually exclusive with `example_id`. | | `example_id` | Built-in example dataset to validate against. | | `fit_results` | In-memory `FitResults` returned by `wait_for_completion(...)` or `fit_from_example(...)`. Mutually exclusive with `parameterized_model_id`. | | `parameterized_model_id` | A saved ECM parameterized model (e.g. from `save_to_project`). | Optional arguments: * `start_step`, `end_step` — inclusive step bounds applied to the held-out measurement. * `initial_soc` — known SOC (0–1) at the start of the held-out trace. When omitted it is recovered from the trace's first voltage via the fitted OCV(SOC) curve (assumes the trace starts near rest); pass it explicitly if the trace starts mid-load. * `capacity` — cell capacity \[Ah] used for SOC integration. Defaults to the fit/model capacity (the SOC reference the curves were fitted against); it is never re-estimated from the held-out trace. ### Example ```python theme={null} # `result` is the FitResults from an ECM fit, e.g.: # job = client.ecm.fit_from_example(...) # result = client.ecm.wait_for_completion(job) # Validate the in-memory fit against a held-out measurement val = client.ecm.validate( measurement_id="held-out-measurement-id", # a load case NOT used in the fit fit_results=result, ) print(f"RMSE: {val.rmse_mV:.2f} mV " f"(MAE {val.mae_mV:.2f} mV, Max {val.max_mV:.2f} mV)") # Or validate a previously saved Parameterized Model. `save_to_project` # returns a response whose `parameterized_model_id` identifies the saved model: # saved = client.ecm.save_to_project(name=..., cell_spec_id=..., fit_results=result) val = client.ecm.validate( measurement_id="held-out-measurement-id", parameterized_model_id=saved.parameterized_model_id, ) ``` ### Results The returned `ValidationResults` object carries error metrics plus aligned, downsampled traces ready to plot as a two-panel (overlay + residual) figure: | Field | Description | | ----------------------------- | ------------------------------------------------ | | `rmse_mV`, `mae_mV`, `max_mV` | Voltage error metrics in millivolts. | | `time` | Downsampled time grid \[s]. | | `data_voltage` | Held-out measured voltage \[V]. | | `model_voltage` | Re-simulated model voltage \[V]. | | `residual_mV` | Model − data residual \[mV]. | | `initial_soc`, `capacity_Ah` | Values used for the SOC integration. | | `num_rcs`, `model_source` | RC-pair count and origin of the validated model. | A typical plot overlays `model_voltage` and `data_voltage` against `time` in one row, with `residual_mV` against `time` in a second row sharing the x-axis: ```python theme={null} import matplotlib.pyplot as plt fig, (ax_v, ax_e) = plt.subplots(2, 1, sharex=True, figsize=(9, 6)) ax_v.plot(val.time, val.data_voltage, label="data") ax_v.plot(val.time, val.model_voltage, label="model", linestyle="--") ax_v.set_ylabel("Voltage [V]") ax_v.legend() ax_v.set_title(f"Held-out validation — RMSE {val.rmse_mV:.1f} mV") ax_e.plot(val.time, val.residual_mV) ax_e.axhline(0, color="k", linewidth=0.8) ax_e.set_ylabel("Error [mV]") ax_e.set_xlabel("Time [s]") fig.tight_layout() ``` ## Next steps * [Upload measurements](/data/uploading) to a project so you can fit them in place * Create a [Parameterized Model](/build/parameterized-models) with your fitted parameters * Learn about [ECM and other model types](/build/models) available in Ionworks Studio * Explore the [data format requirements](/data/format) for uploading cycling data # Current-driven fit Source: https://docs.ionworks.com/build/parameterize/examples/current_driven Fit an exchange-current density from discharge data with ionworks-schema. Build the fit with `ionworks-schema`, submit it through the API client, and read the typed result. This example runs on synthetic sample data bundled with `ionworks-schema`, so you can copy it and run it as-is. Swap `iws.example_data(...)` for your own export, or read a measurement from the platform with `client.cell_measurement`. Plotting the fit needs `ionworks-schema[plot]`. ```python theme={null} from ionworks import Ionworks import ionworks_schema as iws import matplotlib.pyplot as plt import pandas as pd import pybamm # Synthetic example discharge data (Time [s], Current [A], Voltage [V]) per C-rate. # Swap in your own export, or read a measurement with `client.cell_measurement`. data = {"0.5 C": pd.read_csv(iws.example_data("current_driven_discharge"))} # The Arrhenius form below needs an activation energy, which Chen2020 # does not define; zero gives j0 no temperature dependence. baseline = pybamm.ParameterValues("Chen2020") baseline["Negative electrode reaction activation energy [J.mol-1]"] = 0.0 known = iws.direct_entries.DirectEntry(parameters=baseline) # j0 = j0_ref (c_e/c_e0)^0.5 (c_s/c_smax)^0.5 (1 - c_s/c_smax)^0.5 # exp(E_r/R (1/T_ref - 1/T)), replacing Chen2020's own j0 function. j0_function = iws.direct_entries.arrhenius_butler_volmer_exchange_current_density( electrode="negative" ) # We fit j0_ref. parameters = { "Negative electrode reference exchange-current density [A.m-2]": iws.Parameter( "Negative electrode reference exchange-current density [A.m-2]", initial_value=1.0, bounds=(0.1, 10.0), ), } fit = iws.DataFit( objectives={ rate: iws.objectives.CurrentDriven( data=df, options=iws.objectives.CurrentDrivenOptions( model=pybamm.lithium_ion.SPMe() ), ) for rate, df in data.items() }, parameters=parameters, cost=iws.costs.RMSE(), # Capped to keep the example quick; a harder fit needs more. optimizer=iws.optimizers.ScipyMinimize(method="Nelder-Mead", max_iterations=20), ) client = Ionworks() submission = client.pipeline.create( iws.Pipeline({"known": known, "j0_function": j0_function, "fit": fit}) ) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("fit") figs = fit_result.plot_fit_results() plt.show() ``` # Cycle-ageing fit Source: https://docs.ionworks.com/build/parameterize/examples/cycle_ageing Fit an SEI solvent diffusivity from cycle-ageing data with ionworks-schema. Build the fit with `ionworks-schema`, submit it through the API client, and read the typed result. This fits `LLI [%]`, a built-in default metric, so it needs no `metrics` option. A custom metric is expressible too, as a `metrics` config — this one is last-minus-first of the cycle-wise discharge capacity over each cycle's first step: ```python theme={null} metrics = { "C/5 capacity [A.h]": { "type": "ComposedMetric", "operation": "sub", "left": { "type": "CyclewiseMetric", "step": 0, "metric": {"type": "Last", "variable": "Discharge capacity [A.h]"}, }, "right": { "type": "CyclewiseMetric", "step": 0, "metric": {"type": "First", "variable": "Discharge capacity [A.h]"}, }, } } ``` The summary data below has no discharge-capacity column to compare it against, so this fit omits it. This example runs on synthetic sample data bundled with `ionworks-schema`, so you can copy it and run it as-is. Swap `iws.example_data(...)` for your own export, or read a measurement from the platform with `client.cell_measurement`. Plotting the fit needs `ionworks-schema[plot]`. ```python theme={null} from ionworks import Ionworks import ionworks_schema as iws import matplotlib.pyplot as plt import pandas as pd import pybamm # Synthetic example summary, one row per RPT: Cycle number and LLI [%]. Swap in your # own export, or read a measurement with `client.cell_measurement`. data = pd.read_csv(iws.example_data("cycle_ageing_summary")) # A fit needs a complete parameter set, not just the ones you know. known = iws.direct_entries.DirectEntry( parameters=dict(pybamm.ParameterValues("Chen2020")) ) # RPT: a slow C/5 discharge (the LLI read) then a rest. `experiment` also # takes UCP: https://docs.ionworks.com/simulate/universal-cycler-protocol rpt = [ { "type": "c-rate", "value": 0.2, "terminations": [{"type": "voltage", "value": 2.5}], "period": 60.0, }, {"type": "rest", "duration": "10 minutes"}, ] # Faster cycle that ages the cell between RPTs. Both steps are constant # current; add a voltage step for a CV taper. cc_cycle = [ { "type": "c-rate", "value": 1.0, "terminations": [{"type": "voltage", "value": 2.5}], }, { "type": "c-rate", "value": -0.5, "terminations": [{"type": "voltage", "value": 4.2}], }, ] # 3 ageing cycles + 1 RPT per block; two blocks after an initial RPT gives # the 9 cycles matching Cycle number 0, 4 and 8 in the data. experiment = {"cycles": [rpt] + ([cc_cycle] * 3 + [rpt]) * 2} objectives = { "aging": iws.objectives.CycleAgeing( data=data, options=iws.objectives.CycleAgeingOptions( model=pybamm.lithium_ion.SPMe( options={"SEI": "solvent-diffusion limited"} ), experiment=experiment, objective_variables=["LLI [%]"], # LLI [%] is a default metric, so `metrics` can stay unset. ), ) } # We fit the SEI solvent diffusivity. parameters = { "SEI solvent diffusivity [m2.s-1]": iws.Parameter( "SEI solvent diffusivity [m2.s-1]", initial_value=5e-19, bounds=(1e-20, 1e-18), ), } fit = iws.DataFit( objectives=objectives, parameters=parameters, cost=iws.costs.RMSE(), # Small population and iteration count keep the example quick; a # harder fit needs more. optimizer=iws.optimizers.PSO(population_size=10, max_iterations=15), # PSO is stochastic; the seed makes the run reproducible. options=iws.DataFitOptions(seed=0), ) client = Ionworks() submission = client.pipeline.create(iws.Pipeline({"known": known, "aging": fit})) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("aging") figs = fit_result.plot_fit_results() plt.show() ``` # EIS fit Source: https://docs.ionworks.com/build/parameterize/examples/eis Fit an exchange-current density from EIS data with ionworks-schema. Build the fit with `ionworks-schema`, submit it through the API client, and read the typed result. This example runs on synthetic sample data bundled with `ionworks-schema`, so you can copy it and run it as-is. Swap `iws.example_data(...)` for your own export, or read a measurement from the platform with `client.cell_measurement`. Plotting the fit needs `ionworks-schema[plot]`. ```python theme={null} from ionworks import Ionworks import ionworks_schema as iws import matplotlib.pyplot as plt import pandas as pd import pybamm # Synthetic example spectrum: Frequency [Hz], Z_Re [Ohm], Z_Im [Ohm]. Swap # in your own export, or read a measurement with `client.cell_measurement`. data = pd.read_csv(iws.example_data("eis_synthetic")) # The Arrhenius form below needs an activation energy, which Chen2020 # does not define; zero gives j0 no temperature dependence. baseline = pybamm.ParameterValues("Chen2020") baseline["Negative electrode reaction activation energy [J.mol-1]"] = 0.0 known = iws.direct_entries.DirectEntry(parameters=baseline) # j0 = j0_ref (c_e/c_e0)^0.5 (c_s/c_smax)^0.5 (1 - c_s/c_smax)^0.5 # exp(E_r/R (1/T_ref - 1/T)), replacing Chen2020's own j0 function. j0_function = iws.direct_entries.arrhenius_butler_volmer_exchange_current_density( electrode="negative" ) # We fit j0_ref. parameters = { "Negative electrode reference exchange-current density [A.m-2]": iws.Parameter( "Negative electrode reference exchange-current density [A.m-2]", initial_value=1.0, bounds=(0.1, 10.0), ), } objectives = { "impedance": iws.objectives.EIS( data=data, options=iws.objectives.EISOptions( model=pybamm.lithium_ion.DFN(options={"surface form": "differential"}) ), ) } fit = iws.DataFit( objectives=objectives, parameters=parameters, cost=iws.costs.RMSE(), # Capped to keep the example quick; a harder fit needs more. optimizer=iws.optimizers.ScipyMinimize(method="Nelder-Mead", max_iterations=20), ) client = Ionworks() submission = client.pipeline.create( iws.Pipeline({"known": known, "j0_function": j0_function, "eis": fit}) ) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("eis") figs = fit_result.plot_fit_results() plt.show() ``` # MSMR half-cell OCP fit Source: https://docs.ionworks.com/build/parameterize/examples/msmr_ocp Fit MSMR species parameters to half-cell OCP data with ionworks-schema. Build the fit with `ionworks-schema`, submit it through the API client, and read the typed result. This fits one direction. For lithiation/delithiation hysteresis, set `direction` on `iws.models.MSMRHalfCellModelOptions` and give each direction its own objective; only the combined single-plot overlay has no schema equivalent. This example runs on synthetic sample data bundled with `ionworks-schema`, so you can copy it and run it as-is. Swap `iws.example_data(...)` for your own export, or read a measurement from the platform with `client.cell_measurement`. Plotting the fit needs `ionworks-schema[plot]`. ```python theme={null} from ionworks import Ionworks import ionworks_schema as iws import matplotlib.pyplot as plt import pandas as pd import pybamm # Synthetic example half-cell OCP: cumulative Capacity [A.h], falling Voltage [V]. # Swap in your own export, or read a measurement with `client.cell_measurement`. data = pd.read_csv(iws.example_data("msmr_half_cell_ocp")) known = iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, ) # Seed the species from the library material closest to your chemistry. material = iws.Material.from_library("Graphite - Verbrugge 2017") # The library holds one value per species; scalarize_dict expands each list # into the indexed names the model uses, e.g. "... (0) [V]". initial_values = pybamm.scalarize_dict( {k: v for k, v in material.parameter_values.items() if "host site" in k} ) # Seed capacity and lower excess capacity from the observed window. Q_data = float(data["Capacity [A.h]"].max() - data["Capacity [A.h]"].min()) initial_values["Negative electrode capacity [A.h]"] = Q_data initial_values["Negative electrode lower excess capacity [A.h]"] = 0.01 * Q_data def bounds_function(var, initial_value): if "host site occupancy fraction" in var: # allow +/- 0.1 of the initial value, staying a fraction return (max(0.0, initial_value - 0.1), min(1.0, initial_value + 0.1)) elif "ideality factor" in var: return (1e-2, 100.0) elif "standard potential" in var: # allow +/- 200 mV of the initial value return (initial_value - 0.2, initial_value + 0.2) elif "capacity" in var: # allow +/- 2% of the initial value return (initial_value * 0.98, initial_value * 1.02) else: raise ValueError(f"No bounds defined for parameter: {var}") def prior_function(var, initial_value): if "standard potential" in var: return iws.stats.Normal(mean=initial_value, std=0.01) else: return None parameters = {} for name, value in initial_values.items(): parameter = iws.Parameter( name, initial_value=value, bounds=bounds_function(name, value), prior=prior_function(name, value), ) # Log10 the ideality factor since it may span several orders of magnitude. if "ideality factor" in name: parameters[name] = iws.transforms.Log10(parameter) else: parameters[name] = parameter objectives = { "msmr": iws.objectives.MSMRHalfCell( data=data, options=iws.objectives.MSMRHalfCellOptions( model=iws.models.MSMRHalfCellModel( electrode="negative", options={"species format": "Xj"} ) ), ) } fit = iws.DataFit( objectives=objectives, parameters=parameters, # No cost: MSMRHalfCell's default weights capacity against the # differentials. ScipyLeastSquares is its default too, capped for speed. optimizer=iws.optimizers.ScipyLeastSquares(max_nfev=50), ) client = Ionworks() submission = client.pipeline.create(iws.Pipeline({"known": known, "msmr": fit})) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) fit_result = result.element("msmr") figs = fit_result.plot_fit_results() plt.show() ``` # Pipelines Overview Source: https://docs.ionworks.com/build/parameterize/overview Build a parameterization pipeline with ionworks-schema and submit it through ionworks-api. A pipeline is an ordered set of **elements** — `DirectEntry`, `Calculation`, `DataFit`, `Validation` — that together produce a parameterised cell model. Build the pipeline with [`ionworks-schema`](https://schema.docs.ionworks.com/) and submit it with [`ionworks-api`](https://github.com/ionworks/ionworks-api). For the conceptual model and what each element type does, see the [Pipelines Guide](/guide/pipelines/overview). ## A minimal pipeline ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks # Known parameters from a datasheet or literature known = iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, source="datasheet", ) # A simple capacity calculation capacity_pos = iws.calculations.ElectrodeCapacity(electrode="positive") capacity_neg = iws.calculations.ElectrodeCapacity(electrode="negative") # Assemble and submit pipeline = iws.Pipeline( {"known": known, "Q_pos": capacity_pos, "Q_neg": capacity_neg}, name="Capacity pipeline", ) client = Ionworks() submission = client.pipeline.create(pipeline) print(submission.id) ``` `client.pipeline.create()` accepts either an `iws.Pipeline` instance or the dict returned by `pipeline.to_config()` (useful when you want to inspect the payload before sending). ## Element types | Schema | Element type | Use for | | --------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `iws.direct_entries.DirectEntry` | `entry` | Drop in known parameter values (literature, datasheet, manual entry) | | `iws.direct_entries.DirectEntryFunctionSchema` subclasses | `entry` | Pre-built parameterisations (electrolyte, OCP, defaults) — see [Direct Entries](/build/parameterize/direct-entries) | | `iws.calculations.*` | `calculation` | Derive parameters from others — see [Calculations](/build/parameterize/calculations/geometry-capacity) | | `iws.DataFit` / `iws.ArrayDataFit` | `data_fit` | Fit unknown parameters to measured data — see [Data Fitting](/build/parameterize/data-fitting/overview) | | `iws.Validation` | `validation` | Check fitted parameters against held-out data | The element name (the dict key) appears in the pipeline report. The order matters: each element receives the parameters produced by everything before it. ## Submitting and retrieving results See [Python API](/build/parameterize/api) for the full reference. The common flow: ```python theme={null} submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) result = client.pipeline.result(submission.id) print(result.element("fit").parameter_values) ``` Once submitted, a pipeline can also be updated, cancelled, or deleted: ```python theme={null} client.pipeline.update(submission.id, name="Renamed pipeline") client.pipeline.cancel(submission.id) client.pipeline.delete(submission.id) ``` The conceptual model of pipeline elements and their composition. `client.pipeline.create / get / list / update / cancel / delete / result / wait_for_completion`. # Validation Source: https://docs.ionworks.com/build/parameterize/validation Check fitted parameters against held-out experimental data with iws.Validation. A `Validation` element takes the parameters produced earlier in the pipeline, simulates the experiments listed in `objectives`, and compares those simulations to the measured data. It is how you check whether a fit generalises beyond the data it was fit on. ## A minimal validation ```python theme={null} import pybamm import ionworks_schema as iws from ionworks import Ionworks known = iws.direct_entries.DirectEntry( parameters={"Ambient temperature [K]": 298.15}, ) # Held-out experiment(s) to validate against val = iws.Validation( objectives={ "0.5C": iws.objectives.CurrentDriven( data_input="file:examples/data/chen_synthetic_0.5C/time_series.csv", options={"model": pybamm.lithium_ion.SPMe()}, ), }, summary_stats=[iws.costs.RMSE(), iws.costs.MAE(), iws.costs.Max()], ) pipeline = iws.Pipeline({"known": known, "validate": val}) client = Ionworks() submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id) ``` If `summary_stats` is omitted, sensible defaults are filled in so the report carries the same physical units as the measurements. `Validation.objectives` is validated against the same discriminated `type` union as `DataFit.objectives`: unknown objective types, missing or conflicting discriminators, unknown inner keys, and bogus scalar values (e.g. `{"bad": "RMSE"}`) are rejected at submission time. See [Strict objective validation](/build/parameterize/data-fitting/overview#strict-objective-validation) for the full list of rules and examples. ## Retrieving validation output A validation element returns a `ValidationResult`, carrying the per-objective summary statistics and able to plot the comparison it ran: ```python theme={null} result = client.pipeline.result(submission.id) validate = result.element("validate") print(validate.summary_stats) validate.plot_fit_results() # measured vs model, one figure per objective ``` The per-point comparison behind those figures is persisted separately and fetched on first access, so you no longer read the metadata blob to plot it. `result.element_results["validate"]["summary_stats"]` still gives the raw statistics, and `get_element_metadata` the raw blob, if you want them: ```python theme={null} metadata = client.pipeline.get_element_metadata(submission.id, "validate") ``` The element name (`"validate"` above) is whatever key you used in the pipeline's `elements` dict — a pipeline can run several validation elements under names like `"validate_pristine"` and `"validate_aged"`. ## Objectives without validation plots A few objective types — most notably `iws.objectives.Pulse` and other feature-extraction objectives — do not produce time-series validation plots, because the "measurement" the fit compares against is a derived feature (a pulse resistance, a fitted slope, …) rather than a continuous trace. When a validation step runs against one of these objectives, the **validation element's** result comes back as a flat dict carrying a `validation_not_supported` flag at the top level instead of plot data — the whole element, not a per-objective entry: ```python theme={null} result = client.pipeline.result(submission.id) # The validation element (named "validate" here, after the key you gave it in # the pipeline) carries the flag at the top level of its result. validation = result.element_results["validate"] if validation.get("validation_not_supported"): print(validation["message"]) # → "Validation plots are not supported for this objective type." ``` The fit itself still runs and its summary statistics are still reported — only the per-point validation plots are unavailable. In the UI, the validation tab shows this message rather than a blank chart or a generic error. ## Validating after a fit The common pattern is to run the fit and the validation in the same pipeline so they share parameters automatically: ```python theme={null} pipeline = iws.Pipeline( { "known": known, "fit": fit, # iws.DataFit, see /build/parameterize/data-fitting/overview "validate": val, }, ) ``` The validation step receives the best-fit parameters from `fit` and runs the held-out experiments against them. ## Inspecting validation plots in Ionworks Studio Validation and data-fit results render as interactive plots in the Ionworks Studio web app. The plots support: * **Zoom**: Click and drag to zoom into a region * **Pan**: Hold shift and drag to pan * **Reset**: Double-click to reset the view * **Hover**: Move cursor over data points to see values The initial view shows a downsampled trace so large jobs render quickly. When you zoom in, the app automatically refetches a higher-resolution slice for the visible x-range. A loading indicator appears while data is being fetched. This applies to both `Validation` elements and the validation plots produced by `DataFit` elements. The trace data and per-plot layout are persisted separately from `element_results`; the SDK exposes the same payload via `client.pipeline.get_element_metadata(submission_id, element_name)`. How validation chains with direct entries, calculations, and data fits. Producing the parameters that validation then checks. # Parameterized Models Source: https://docs.ionworks.com/build/parameterized-models Combine a model with validated parameter values to create a ready-to-run simulation engine for a specific cell A **Parameterized Model** is a complete, ready-to-run simulation engine that combines: 1. A specific **[Model](/build/models)** (e.g., `SPM`, `DFN`) - references a Model from the Models table 2. A complete and validated set of **Parameters** for that model This entire package is then linked to a specific [`Cell Specification`](/core-concepts/cells), as the parameters are tuned to that cell's unique chemistry and design. Think of a Parameterized Model as a fully-parameterized, ready-to-run simulation engine for a particular cell. ### Models vs Parameterized Models * **Models**: Define *how* to simulate (the mathematical framework) * **Parameterized Models**: Define *what* to simulate (the model + specific parameter values) For example, you might have: * One **Model** called "DFN (Full Cell)" that defines the Doyle-Fuller-Newman model structure * Multiple **Parameterized Models** that use this same Model but with different parameter sets: * "NMC Cell with Chen2020 Parameters" * "NMC Cell with Custom Parameters" * "LFP Cell with OKane2022 Parameters" All three Parameterized Models reference the same Model, but each has its own unique set of parameters. **Parameterized Models are Immutable** Once a parameterized model is created and used in a simulation, it cannot be edited. This is a key feature that ensures the reproducibility of your results, as a simulation is permanently linked to the exact parameterized model version that generated it. To make changes to an existing parameterized model, you must **clone** it. This creates a new version that you can modify, preserving the history and integrity of your previous work. ### Creating a New Parameterized Model When creating a parameterized model, you first select a Model (either a system model or one you've created), then provide a set of parameters. There are several ways to get started with parameters: * **From Library:** Start with a pre-defined parameter set from a public or internal library, such as those from published academic papers. This is often the best starting point. * **Clone an existing parameterized model:** If you have an existing parameterized model in Ionworks Studio that you want to iterate on, you can clone it. This is the perfect workflow for studying the effect of changing one or two parameters. * **From a BPX File:** Upload a JSON file formatted according to the Battery Parameter eXchange (BPX) standard. This allows you to import parameter sets from other tools or collaborators. * **From a PyBaMM JSON File:** Upload parameters in PyBaMM's serialized JSON format. This is useful when you have parameters exported from PyBaMM or other tools that use PyBaMM's parameter format. ### Configuring Parameters After selecting your starting point, you can review and edit the parameters. #### Parameter Types Parameters in Ionworks Studio are not just simple numbers. They can be defined in several ways to capture complex physical behavior: * **Value:** A single numerical value (e.g., `Electrode height [m] = 0.05`). * **Expression:** A PyBaMM expression that references other parameters (e.g., `1 - Parameter("porosity")`). See [Dependent parameter expressions](#dependent-parameter-expressions) below. * **Function:** A mathematical expression, often dependent on other variables like temperature `T` (e.g., `exp(-2500 / (T - 229))`). * **Interpolant:** A lookup table defined by a set of data points (e.g., for Open Circuit Potential vs. Stoichiometry). [Learn more about interpolants](/build/interpolants). The parameter editor organizes parameters into logical groups (e.g., "Cell", "Anode", "Cathode") to make them easier to navigate. #### Dependent parameter expressions Some parameters can be defined as **expressions** that reference other parameters by name. For example, you might set a parameter to `1 - Parameter("porosity")` so it is computed from the porosity parameter. These are called **dependent parameters**. To use an expression: 1. Click the **change circle icon** (⟳) next to the parameter value. 2. Select **Expression** from the menu (plus-in-circle icon). 3. Type your expression in the dedicated expression field. Use `Parameter("parameter name")` to reference another parameter—for example, `1 - Parameter("Negative electrode porosity")`. 4. When you leave the field (blur or press Enter), the expression is validated and converted. If it is valid, the computed value is shown below the expression based on the current parameter set. The expression field shows a placeholder example (e.g., `1 - Parameter("porosity")`) when empty. Referenced parameters are automatically added to the parameter list if they do not already exist, so you can fill in their values in the same group. Expressions support standard Python operations (e.g., `+`, `-`, `*`, `/`, `**`) and functions such as `exp`, `log`, `sqrt`, and `tanh`. You cannot reference parameters that are defined as functions or interpolants—only scalar values and other expressions. Circular references (e.g., A depends on B, B depends on A) are detected and reported as an error. ### Parameter Validation Before you can create a parameterized model, Ionworks Studio runs an automatic **Parameter Validation** check. This service helps ensure that your parameter set is physically realistic and self-consistent. The validator checks for: * Deviations from known reference values. * Inconsistencies between related parameters. Each check has a status: * **Success:** The parameter value is within the expected range. * **Warning:** The parameter value is plausible but deviates significantly from the reference. * **Error:** The parameter value is likely to be physically unrealistic or cause the simulation to fail. For some warnings and errors, the validator provides an **autofix** option. This allows you to automatically apply a suggested correction to the parameter, helping you quickly resolve issues and ensure your parameterized model is ready for simulation. ### Downloading a parameterized model You can download a parameterized model as a ZIP file for offline use or to share with collaborators. From the parameterized model details page, click the **Download** button. The downloaded ZIP file contains two JSON files: * **`model.json`** — the model configuration (e.g., model type and options). * **`parameters.json`** — all parameter values, including scalar values, interpolants, expression functions, and dependent parameter expressions. Metadata keys such as `version` and `citations` are excluded from the export. The ZIP file is named after the parameterized model (e.g., `My_NMC_Cell.zip`). ### Default parameterized model Each [cell specification](/core-concepts/cells) can mark one of its parameterized models as its **default model**. The first parameterized model created for a spec is auto-set as the default; later creations don't override an existing default. Downstream workflows that need to pick a parameterized model for the cell start from the default. You can change the default from the spec's **Parameterized models** tab or from the parameterized model detail page (via a **Set as default** action), or through the API. See [Default parameterized model](/core-concepts/cells#default-parameterized-model) for details. ### Editing parameterized models **Parameterized Models** can have their name and description updated, but the model reference and parameters cannot be changed after creation. This ensures reproducibility—simulations are permanently linked to the exact parameterized model version that generated them. To modify a parameterized model's parameters or model reference, you must **clone** it. Cloning creates a new parameterized model that you can modify, preserving the history and integrity of your previous work. ### Simulation settings A parameterized model can carry a **simulation\_settings** block (mesh + solver kwargs) that pins the grid and solver a fitted parameter set was built against. Parameter-level settings take precedence over the base model's, so this is where fitted-parameter mesh refinements usually belong. See [Simulation settings](/build/simulation-settings). ## Next Steps * Learn how to [run simulations](/simulate/simulations) with your parameterized model * Explore [optimization](/optimize/overview) to find optimal parameter values # Simulation settings Source: https://docs.ionworks.com/build/simulation-settings Attach persistent mesh and solver settings to a model or parameterized model so every simulation runs on the grid the parameters need. **Simulation settings** are an optional persistent bag of PyBaMM `Simulation` keyword arguments (`var_pts`, `submesh_types`, `spatial_methods`, `solver`) that Ionworks stores on a [Model](/build/models) or [Parameterized Model](/build/parameterized-models) and re-applies every time the model is simulated. A model's equations do not depend on the mesh or solver, but the settings a fitted parameter set *needs* often do. A fitted solid diffusivity with a steep near-surface gradient, for example, needs a refined, surface-clustered particle mesh to solve accurately — and defaulting back to PyBaMM's coarse grid would give the wrong voltage. Simulation settings let you pin those requirements to the model or parameter set once, so every downstream simulation runs on the grid and solver the parameters were fit against. ## When to use them Attach simulation settings when either of these is true: * Your parameters were fit with a non-default mesh (for example, a refined particle mesh with a `Chebyshev1DSubMesh`) and you want that mesh applied automatically whenever the parameterized model runs. * A specific model configuration requires a particular solver or solver tolerance to be numerically stable. If your model runs correctly on PyBaMM's defaults, you do not need to set anything — leave `simulation_settings` unset (or `None`) and PyBaMM's own `default_var_pts` / `default_submesh_types` / `default_spatial_methods` / `default_solver` are used. ## What you can configure All fields are optional. Any field you omit falls back to the model default. | Field | Type | Purpose | | ----------------- | --------------------------------- | ----------------------------------------------------------------------------------------------- | | `var_pts` | `{spatial_variable: int}` | Number of mesh points per spatial variable (e.g. `{"r_n": 16, "r_p": 16}`). | | `submesh_types` | `{domain: submesh config}` | Serialized `pybamm.MeshGenerator` per domain (e.g. `"negative particle"`). | | `spatial_methods` | `{domain: spatial method config}` | Serialized `pybamm.SpatialMethod` per domain. | | `solver` | `dict` or `pybamm.BaseSolver` | Serialized `pybamm.BaseSolver.to_config()` payload (solver class name plus tolerances/options). | Every class named in a stored block is checked against a static allowlist on write, so a class that isn't listed below is rejected with a `BAD_REQUEST` rather than stored and resolved later. Allowed spatial variables for `var_pts`: `x_n`, `x_s`, `x_p`, `r_n`, `r_p`, `R_n`, `R_p`, `y`, `z`, and their `_prim` / `_sec` variants for composite electrodes. Values must be positive integers. Allowed submesh classes: `Uniform1DSubMesh`, `Exponential1DSubMesh`, `Chebyshev1DSubMesh`, `UserSupplied1DSubMesh`, `SpectralVolume1DSubMesh`, `SymbolicUniform1DSubMesh`, `SubMesh0D`. Allowed spatial-method classes: `FiniteVolume`, `SpectralVolume`, `ZeroDimensionalSpatialMethod`. Allowed solver classes: `IDAKLUSolver`, `AlgebraicSolver`, `NonlinearSolver`. `CasadiSolver` and `ScipySolver` are deprecated in PyBaMM and cannot be persisted. See [Ionworks DAE Solver](/guide/modeling/ionworks-solver) for the fast default used behind every simulation on the platform. `geometry` cannot be persisted. A stored geometry override is rejected on write with `simulation_settings.geometry is not supported` — deserializing one reconstructs arbitrary PyBaMM expression-tree symbols from JSON, which the class allowlist above cannot bound. Persist `var_pts` / `submesh_types` / `spatial_methods` / `solver` instead, and pass a geometry override at run time via `simulation_kwargs` if you need one. ## Precedence At simulation time, Ionworks merges the persisted settings with a fixed precedence — higher layers win, per key: ``` runtime simulation_kwargs > parameterized model > model > pybamm defaults ``` * **Mesh keys** (`var_pts`, `submesh_types`, `spatial_methods`) merge *per key* — and per key over the model's own PyBaMM defaults, so the map handed to `Simulation` is always complete. A parameterized model can therefore refine just `r_n` and `r_p` without restating the rest of the grid, and a runtime `simulation_kwargs` that names only `r_p` overrides that one entry rather than dropping the persisted `r_n`. * **`solver`** is replaced *wholesale* by the highest layer that sets it — a partial solver is ambiguous. * An explicit per-run `simulation_kwargs` override always wins, so debugging a single run never requires editing the persisted settings. Pipeline **design objectives** are the one exception: only the resolved `var_pts` and `solver` are folded into them. A persisted `submesh_types` or `spatial_methods` is deliberately not applied there, because a design objective needs the symbolic submesh the design-optimization converter installs. Protocol simulations and the model/parameterized-model simulate path apply all four keys. ## Attaching settings through the API Both `client.model` and `client.parameterized_model` accept a `simulation_settings` field on create and update. See the [Python API reference](/build/api) for the full sub-client surface. ### On a model ```python theme={null} from ionworks import Ionworks client = Ionworks() model = client.model.create({ "name": "DFN with refined particle mesh", "config": {"type": "DFN"}, "simulation_settings": { "var_pts": {"r_n": 20, "r_p": 20}, }, }) ``` ### On a parameterized model Parameter-specific settings take precedence over the base model's settings, so this is where fitted-parameter mesh refinements usually belong: ```python theme={null} param_model = client.parameterized_model.create("your-cell-spec-id", { "name": "NMC622 fitted parameters", "model_id": model.id, "parameters": { "Negative electrode diffusivity [m2.s-1]": 3.3e-14, }, "simulation_settings": { "var_pts": {"r_n": 32, "r_p": 32}, "solver": { "type": "IDAKLUSolver", "atol": 1e-8, "rtol": 1e-6, }, }, }) ``` Update the settings later: ```python theme={null} client.parameterized_model.update( "your-cell-spec-id", param_model.id, {"simulation_settings": {"var_pts": {"r_n": 40, "r_p": 40}}}, ) ``` The update is partial at the *field* level, not inside `simulation_settings`: whatever you send **replaces the whole stored block**. The call above discards the `solver` persisted at create time. To change one key, read the current settings, merge locally, and send the complete block: ```python theme={null} current = client.parameterized_model.get(param_model.id).simulation_settings or {} client.parameterized_model.update( "your-cell-spec-id", param_model.id, {"simulation_settings": {**current, "var_pts": {"r_n": 40, "r_p": 40}}}, ) ``` The per-key merging described under [Precedence](#precedence) applies across the model / parameterized-model / runtime *layers* at simulation time — it does not merge an update into the row you are writing. Passing `"simulation_settings": None` clears the field and inherits the base model's settings (or the PyBaMM defaults if the base model has none either). ## Attaching settings in a pipeline The [`ionworks_schema`](/build/parameterize/overview) model classes (`GITTModel`, `MSMRFullCellModel`, `MSMRHalfCellModel`, `LumpedSPMR`, `LumpedSPMeR`, `ECM`, ...) accept a `simulation_settings` argument that is serialized alongside the model config when the pipeline is submitted: ```python theme={null} import pybamm import ionworks_schema as iws model = iws.models.GITTModel( options={"working electrode": "positive"}, simulation_settings=iws.models.SimulationSettings( var_pts={"r_p": 32}, submesh_types={ "positive particle": pybamm.MeshGenerator( pybamm.Chebyshev1DSubMesh, ), }, ), ) ``` The pipeline serializes the live `MeshGenerator` / `BaseSolver` objects to their canonical JSON form automatically, so the same settings survive a round trip through the API. ## Serialized form Under the hood, simulation settings are stored as a flat JSON dict — this is also what `client.model.get(...).simulation_settings` returns and what the migration column holds. A typical block looks like: ```json theme={null} { "var_pts": {"r_n": 20, "r_p": 20}, "submesh_types": { "negative particle": { "$type": "type", "class": "pybamm.meshes.one_dimensional_submeshes.Chebyshev1DSubMesh", "submesh_params": {} } }, "solver": { "type": "IDAKLUSolver", "atol": 1e-8, "rtol": 1e-6 } } ``` You can pass this dict form directly, or build it with the `SimulationSettings` schema class and let `to_config()` produce the same payload for you. # Projects API Source: https://docs.ionworks.com/core-concepts/api Create, list, filter, update, and delete projects programmatically with the ionworks-api Python sub-client The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package provides a sub-client for managing [projects](/core-concepts/projects-studies) programmatically. For installation and authentication, see the [Python API client](/api-client) page. ## Listing projects ```python theme={null} from ionworks import Ionworks client = Ionworks() # List all projects projects = client.project.list() for project in projects: print(f"{project.name} (ID: {project.id})") # Filter by name (case-insensitive substring match) projects = client.project.list(name="NMC") # Paginate results projects = client.project.list(limit=10, offset=20) print(f"Showing {projects.count} of {projects.total} projects") ``` Supported filters: `name`, `description`, `created_at`, `updated_at` (each with `_gt` / `_lt` variants for ranges), `order_by`, `order`. Filters and sort parameters are applied server-side, so you can page through large project lists without fetching every row. Projects have no `created_by_email` filter — the `projects` table records no creator, so there is nothing to match against. Use `client.model.list()` or another resource if you need to filter by the user who created a record. ## Getting a project ```python theme={null} project = client.project.get("your-project-id") print(f"{project.name}: {project.description}") ``` ## Creating a project ```python theme={null} project = client.project.create({ "name": "NMC622 Characterization", "description": "Parameter identification for NMC622/Graphite cells", }) print(f"Created project: {project.id}") ``` ## Updating a project ```python theme={null} project = client.project.update("your-project-id", { "name": "NMC622 Characterization v2", "description": "Updated description", }) ``` ## Deleting a project ```python theme={null} client.project.delete("your-project-id") ``` You can find the ID for any resource from the Ionworks Studio web app. The ID is displayed in the URL when you navigate to a resource's detail page. # Cells Source: https://docs.ionworks.com/core-concepts/cells Define cell specifications with chemistry, capacity, voltage limits, electrode materials, and provenance metadata as the blueprint for your batteries ## Cell Specifications A **Cell Specification** is the blueprint for a cell in Ionworks. It defines the fundamental properties of a cell, acting as a central record for its design and characteristics. Think of it as a master template for a particular type of cell you are working with - the kind of information that would be available in a datasheet. All experimental data and simulations are ultimately linked back to a Cell Specification. It serves as the primary container for organizing all information related to a specific cell chemistry and design. ### Key Properties When you create a Cell Specification, you define: **Basic Information** * **Name** - A unique, descriptive name for your cell specification * **Form Factor** - Physical format (e.g., R2032, 18650, 21700, pouch, prismatic) * **Manufacturer** - Who assembled or manufactured the cell **Electrical Ratings** * **Capacity** - Rated capacity (e.g., 5 Ah). Used to convert C-rate to current in simulations. * **Voltage Min/Max** - Operating voltage limits. Used as default cutoffs in simulations. * **Nominal Voltage** - Nominal cell voltage (optional) * **Energy** - Rated energy (optional) * **Energy Density** - Gravimetric and/or volumetric (optional) * **Max Charge/Discharge Rates** - Maximum C-rates (optional) **Components** * **Anode** - Anode material and properties * **Cathode** - Cathode material and properties * **Electrolyte** - Electrolyte material and properties * **Separator** - Separator material and properties * **Case** - Case type and properties **Source/Provenance** * **DOI** - Digital object identifier for reference papers * **Citation** - Publication citation * **Creator** - Name and ORCID of the data creator * **License** - Data license **Additional Fields** * **Properties** - Custom non-electrical properties (dimensions, assembly method, etc.) * **Notes** - Free-form notes about the cell ## Electrode geometry (teardown data) Electrode geometry — layer thicknesses, porosities, particle radii, active-material volume fractions, and maximum concentrations — is **design metadata stored on the cell specification**, not on a measurement. It lives on the spec's linked component records (`anode`, `cathode`, `electrolyte`, `separator`, `case`) and in the spec-level `properties` field for design-level values that aren't tied to a single component. ### When you need it Geometry is what lets a model be parameterized in **physical constituents** rather than in grouped quantities. The **Doyle–Fuller–Newman (DFN)** and **Single Particle Model with Electrolyte (SPMe)** equations are written in those constituents — layer thicknesses, porosities, particle radii — so these values are structural inputs. Without them the model cannot be assembled, and the build raises an error before any solve. Geometry is **not** what buys you more physics. The Ionworks lumped models (`LumpedSPMR`, `LumpedSPMeR`, `SingleElectrodeLumpedSPMR`) solve the same single-particle physics, but parameterized in electrode capacities, diffusion time constants, and a lumped resistance — quantities you can identify from cell-level cycling alone. A spec with no geometry still runs those, and the equivalent circuit model (ECM), at full fidelity for any question not asked in constituents. What geometry adds is the ability to ask one that is: *what if the particles were smaller, the coating thicker, the N/P ratio different?* See [Models](/build/models) for the full model catalog. A practical check: if a spec's component records are all unset and `properties` is empty, treat the spec as having **no geometry on the platform** and DFN/SPMe simulations will fail to build. The fix is to attach geometry to the spec (below), not to add another measurement. ### Where it can come from The model only needs the numbers — it does not care how they were obtained. Valid sources include: * A physical teardown of the cell * Direct metrology (caliper/micrometer thickness, mercury porosimetry, SEM particle sizing) * The vendor datasheet * Published literature for the same chemistry Record the source and confidence alongside the values (for example as a `source` key inside each component's `properties`) so downstream consumers know whether a number was measured or assumed. ### Attaching geometry to a spec Geometry is attached by updating the spec's components. Use Quantity dicts (`{"value": ..., "unit": ...}`) with human-readable units — `um`, `percent`, `mm`, `mol.m-3` are all parsed automatically. Units use PyBaMM notation (`.`-separated atoms with signed integer exponents); Pint-style strings like `mol/m**3` are also accepted and normalized to PyBaMM notation. ```python theme={null} client.cell_spec.update( spec_id, { "anode": { "material": {"name": "NK-SC", "manufacturer": "Novonix"}, "properties": { "thickness": {"value": 70, "unit": "um"}, "porosity": {"value": 33.6, "unit": "percent"}, "particle_radius": {"value": 5.05, "unit": "um"}, "active_material_volume_fraction": { "value": 62.7, "unit": "percent", }, "current_collector_thickness": {"value": 10, "unit": "um"}, "source": "vendor datasheet", }, }, "cathode": { "material": {"name": "M83E300"}, "properties": { "thickness": {"value": 65, "unit": "um"}, "porosity": {"value": 30.0, "unit": "percent"}, "particle_radius": {"value": 3.8, "unit": "um"}, "active_material_volume_fraction": { "value": 66.0, "unit": "percent", }, "source": "teardown", }, }, "separator": { "material": {"name": "Celgard 2325"}, "properties": { "thickness": {"value": 25, "unit": "um"}, "porosity": {"value": 39.0, "unit": "percent"}, }, }, }, ) ``` After the update, the spec's `anode_id`, `cathode_id`, and `separator_id` are populated and the geometry is available to any parameterized model that resolves parameters from the spec. Geometry attached to the **spec** describes the design target. If a specific physical cell deviates from that design — for instance, a measured electrode loading from a particular build — record the deviation on the cell instance via `measured_properties`. See [Uploading data](/data/uploading). ## Default parameterized model Each cell specification can designate one of its own [parameterized models](/build/parameterized-models) as the **default model** for the spec. Downstream workflows and UI surfaces that need to pick a parameterized model for the cell — for example, when opening the spec detail page or starting a new simulation from the cell — treat the default as the natural starting point. The default is auto-populated: the **first** parameterized model created for a spec (from either the API or an ECM fit) becomes its default. If a default is already set, later creations do not override it. You can change the default (or clear it) at any time from the spec's **Parameterized models** tab, from the parameterized model detail page, or through the API. ### Setting the default from the UI * On a cell spec's **Parameterized models** tab, the current default shows a **Default** chip next to its name. Open the 3-dot menu on any other row and click **Set as default** to move the chip. * On a parameterized model's detail page, the default shows a **Default model** badge in the header. Non-default models show a **Set as default** button in the same place. ### Setting the default through the API Update the spec with the ID of one of its parameterized models. The parameterized model must belong to the same spec — the service rejects foreign models with a 400. ```python theme={null} client.cell_spec.update( spec_id, {"default_parameterized_model_id": pm_id}, ) ``` To clear the default, pass `None` (JSON `null`): ```python theme={null} client.cell_spec.update( spec_id, {"default_parameterized_model_id": None}, ) ``` The current default is available on the spec as `default_parameterized_model_id`, which is `None` when unset. If the default parameterized model is deleted, the spec's `default_parameterized_model_id` is automatically cleared — you don't have to unset it manually before deleting. ## Project ownership Each Cell Specification belongs to a single [Project](/core-concepts/projects-studies) within your [Organization](/core-concepts/organizations). All cell instances, measurements, and parameterized models derived from that specification live inside the same project. The spec's electrode, electrolyte, separator, and case **components** — and the **materials** they reference — are also scoped to the same project. Editing a component or its material on one project's spec doesn't mutate another project's copy, even when the material has the same name, manufacturer, and product ID. If you need to use the same cell design in another project, create a new Cell Specification in that project. Its components and materials become independent copies inside the new project. This keeps each project's cell data, measurements, and parameterized models cleanly scoped. Components and materials without a project — created directly rather than through a cell specification, or predating per-project scoping — remain organization-wide. If specs in different projects reference one of these shared records, editing it affects every spec that references it. ## Finding specs by material You can search in the other direction too — from a material to the specs that use it, or from one spec to others sharing a component material. See [Finding which cells use a material](/data/materials#finding-which-cells-use-a-material). ## Next Steps * Upload experimental data for your cells - see [Data Overview](/data/overview) * Learn about [Models](/build/models) to define the mathematical framework for simulations * Explore [Parameterized Models](/build/parameterized-models) to create ready-to-run simulation engines # Organizations Source: https://docs.ionworks.com/core-concepts/organizations Top-level Ionworks workspace containing projects, cells, models, and members with shared organization-level resources and roles An **Organization** is the top-level container in Ionworks. It represents your team's workspace where all your projects, data, and members are managed. ## Organization Structure ``` Organization ├── Models └── Projects ├── Cell Specifications │ └── Cell Instances → Measurements → Analyses ├── Materials → Property Datasets ├── Parameterized Models ├── Protocols ├── Studies → Simulations ├── Pipelines └── Optimizations ``` ## Key Concepts ### Central Workspace Your organization is the central hub for all your work. Every [Project](/core-concepts/projects-studies) and [Model](/build/models) belongs to your organization, and every [Cell Specification](/core-concepts/cells) and [Parameterized Model](/build/parameterized-models) belongs to a project inside it. ### Shared Resources * **Models** are available to all team members across every project. * **Cell Specifications** and **Parameterized Models** are scoped to a single project. To reuse a cell design in another project, create a new Cell Specification in that project. The exception is a Quick model, which has no cell specification and is shared across the organization. * **Sites** are organization-wide test facilities. Their **Cyclers** and **Channels** belong to both a site and a project, so they sit outside the tree above rather than under one parent — see [Equipment](/operate/equipment). ### Team Collaboration * Invite team members to your organization * Manage access through roles at two levels — organization and project * Give each member the right access per project, rather than all-or-nothing ### Roles Access is granted at two levels, and a member needs both: organization membership says they belong, and a project role says what they can do inside a given project. | Level | Roles | What it controls | | ------------ | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Organization | **Admin**, **Member** | Belonging to the organization. Admins additionally manage members, branding, and organization settings. | | Project | **Project Admin**, **Project Contributor**, **Project Viewer** | What the member can do in that project — view only, create and edit, or also manage the project's access. | Organization membership is the baseline rather than a grant of access: being a Member means you belong to the organization, and your project role decides what you can see and change in each project. A **Project Viewer** can read a project's cells, models, and results but cannot create, edit, or delete anything — write actions simply aren't offered. ## Managing Members Organization admins can invite new members, change roles, deactivate access, or remove members entirely from the organization's **Members** settings. ### Adding a Member Add a member from the Members tab by entering their email address. Email matching is **case-insensitive**, so `Alice@example.com` and `alice@example.com` resolve to the same person — you won't accidentally create a duplicate membership by typing a differently-cased address. The add-by-email flow surfaces two distinct outcomes so you know what happened: * **Newly invited user** — the email doesn't yet belong to an Ionworks account. An invitation email is sent so they can create an account and join the organization. * **Already registered user** — the email belongs to an existing Ionworks user. They're added to the organization immediately and can sign in with their existing credentials. ### Resending Invites and Password Resets From the **Members** tab, an admin can: * **Resend an invite** to a member who hasn't accepted their invitation yet — useful when the original email was missed or expired. * **Send a password reset** to any member — a self-service reset link is emailed to them without touching their account otherwise. Both actions live in the row-level menu next to each member in the list. They send the email only — neither changes the member's role or membership, and neither writes an activity-log entry, so they leave no record in the organization's history. ### Deactivating a Member Deactivating a member blocks their access to the organization while keeping their membership record and role intact. This is the recommended way to temporarily revoke access — for example when someone changes teams, goes on leave, or needs their access paused during an audit — without losing their assignments or history. * **Deactivated members** cannot sign in to the organization. If it is their active organization, they see an *Access deactivated* screen and can switch to another organization they belong to, or contact support. * Their **role and membership are preserved**, so reactivating restores their previous access with no reconfiguration. * Deactivated members appear in the members list with a **Deactivated** badge. To deactivate a member: 1. Open **Organization settings → Members**. 2. Find the member in the list and click **Deactivate**. 3. Confirm in the dialog. The member's role is preserved and they can be reactivated at any time. To reactivate a member, click **Activate** next to their name in the members list. Admins cannot deactivate their own membership, and the organization's last active admin cannot be deactivated. Use **Remove** instead if you want to end a member's association with the organization entirely — this deletes the membership row and any role assignment. ## Branding An organization can show its own logo and name in the sidebar instead of the default Ionworks branding, from **Organization settings → Branding**. Only organization admins can change it; everyone in the organization sees the result. * **Name** — the display name shown beside the logo, up to 120 characters. Leave it unset to show the organization's own name. * **Logo** — a PNG, JPEG, WebP, or SVG image up to 2 MB. Remove it at any time to fall back to the default. ## Next Steps * Create a [Project](/core-concepts/projects-studies) to organize your work * Define [Cell Specifications](/core-concepts/cells) for your batteries * Set up [Models](/build/models) for simulation # Projects Source: https://docs.ionworks.com/core-concepts/projects-studies Group studies, simulations, parameterized models, and optimizations into focused research initiatives within an organization # Projects Projects are high-level containers that help you organize related research efforts in Ionworks. Think of them as folders for different initiatives, research areas, or client work. ## What is a Project? A project groups together all the work related to a specific research area or objective. For example: * **New Electrolyte Development** - Testing different electrolyte formulations * **Customer X Cell Performance** - Analysis work for a specific client * **Next-Gen Anode Materials** - Research on new electrode materials ## Project Structure ``` Project ├── Cell Specifications │ └── Cell Instances → Measurements → Analyses ├── Materials → Property Datasets ├── Parameterized Models ├── Protocols ├── Studies │ └── Simulations ├── Pipelines ├── Optimizations └── Protocol Simulator ``` Each project keeps its own protocols, cells, models, studies, and optimizations. Work in one project stays isolated from the others. ## What You Can Do ### Build * **Create Cell Specifications** - Define the cell designs used in this project * **Create Parameterized Models** - Build ready-to-run simulation engines ### Simulate * **Create Studies** - Set up focused investigations * **Run Simulations** - Execute battery simulations with various conditions * **Visualize Results** - Compare and analyze simulation outputs * **Test Commercial Protocols** - Upload and simulate Maccor, Neware, or Novonix files ### Optimize * **Run Optimizations** - Automatically find optimal parameters for cell design or charging protocols * **Compare Results** - See baseline vs. optimized performance ## Key Features ### Data Sharing All cells, parameterized models, and protocols belong to a project and are available throughout that project. Results from one study can be viewed and compared in others. Cyclers and channels are owned by a project too, even though they don't appear in the diagram above — physically they're organized under **Sites**, which are shared org-wide infrastructure rather than project-scoped containers (see [Organization Structure](/core-concepts/organizations) and [Equipment](/operate/equipment)). ### Built-in protocol catalog When you create a project, Ionworks seeds it with a copy of the built-in [protocol templates](/simulate/experiment-templates) (Constant Current Discharge, GITT, EIS, Cycle Aging, and more). These are yours to edit, clone, or delete within the project — changes in one project don't affect any other. ### Smart Simulation Reuse Ionworks automatically detects duplicate simulations and reuses existing results, saving time and compute resources. ### Collaboration All projects are accessible to organization members. Access is managed by organization-level roles and permissions. ## Getting Started A "Default" project is automatically created when you join. To create additional projects: 1. Navigate to the Projects page 2. Click **"New Project"** 3. Give it a descriptive name ## Next Steps * Learn about [Studies](/simulate/studies) for running simulations * Try [Optimization](/optimize/overview) to find optimal parameters * Explore [Cells](/core-concepts/cells) and [Models](/build/models) # Search Source: https://docs.ionworks.com/core-concepts/search Find projects, studies, simulations, models, optimizations, and cells across your organization with a single API call Search lets you quickly find resources across your organization without querying each endpoint individually. A single query returns matching projects, studies, simulations, models, parameterized models, optimizations, optimization templates, experiment templates, pipelines, cell specifications, cell instances, cell measurements, materials, cyclers, and channels. Search is available both through the REST API and from the global search bar in Ionworks Studio. Open the search bar from the top of any page in Studio, or call the `/search` endpoint directly from your own scripts and tools. ## What you can search | Resource type | Examples of what's matched | | ---------------------- | --------------------------------------------- | | Projects | Project names and descriptions | | Studies | Study names within projects you can access | | Simulations | Simulation names and metadata | | Models | Model names and descriptions | | Parameterized models | Parameterized model names | | Optimizations | Optimization run names | | Optimization templates | Built-in and custom template names | | Experiment templates | Built-in and custom protocol templates | | Pipelines | Pipeline names and descriptions | | Cell specifications | Cell names, chemistries, and identifiers | | Cell instances | Serial numbers and instance identifiers | | Cell measurements | Measurement names and metadata | | Materials | Anode, cathode, and electrolyte materials | | Cyclers | Cycler names and descriptions | | Channels | Channel names and identifiers within a cycler | Search matches against names, descriptions, and other identifying fields for each resource type. Results are scoped to your current organization and respect your project-level permissions — you only see resources you have access to. ## Using search via the API Search is available at the `/search` endpoint. See the **API Reference** tab for the full request and response schema. A typical request specifies the query string and returns matches grouped by resource type: ```bash theme={null} curl -X GET "https://api.ionworks.com/search?q=NMC622" \ -H "Authorization: Bearer $IONWORKS_API_KEY" ``` Results are filtered to the resources your API key has access to. ### Query parameters | Parameter | Type | Default | Description | | -------------- | ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `q` | string | — | Search query. Minimum 2 characters. Required. | | `limit` | int | `25` | Maximum results to return in this page (1–100). | | `offset` | int | `0` | Number of results to skip before this page. Use with `limit` to paginate. | | `per_type` | int | `5` | Maximum results to return per entity type (1–20) before pagination is applied. | | `entity_types` | string (repeatable) | — | Restrict results to these entity types. Omit to search all types. Unknown values are ignored. | | `project_id` | string | — | Narrow the project-filtered entities (studies, pipelines, optimizations) to one project. All other entity types (cell specifications, cell instances, cell measurements, materials, templates) are returned regardless of this value — even though cell specifications now belong to a project, search does not filter them by it. | ### Response shape The response includes the matching `results`, the original `query`, and the pagination fields `total`, `limit`, and `offset` so clients can render page navigation: ```json theme={null} { "query": "NMC622", "results": [ /* ... */ ], "total": 42, "limit": 25, "offset": 0 } ``` `total` reflects all fetched matches across queried entity types, bounded by `per_type` × the number of entity types searched. It is therefore a fetch-level ceiling rather than the true number of matching records in the database — with the default `per_type=5` and 14 entity types it can never exceed 70, even when thousands of records match. Don't rely on `total / limit` to compute an exact page count for large result sets. ### Pagination and filtering example Fetch the second page of cell and material matches, returning up to 10 per type. The `project_id` here narrows only the project-filtered types (studies, pipelines, optimizations); cell and material results are returned regardless: ```bash theme={null} curl -G "https://api.ionworks.com/search" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ --data-urlencode "q=NMC622" \ --data-urlencode "limit=20" \ --data-urlencode "offset=20" \ --data-urlencode "per_type=10" \ --data-urlencode "entity_types=cell" \ --data-urlencode "entity_types=material" \ --data-urlencode "project_id=proj_01HXYZ..." ``` ### Result fields Each result includes an `entity_type`, `id`, `name`, and `project_id` (when the resource lives inside a project). Results for nested resources also include a `parent_id` that points to the containing resource — for example, the `parent_id` of a `cell_instance` result is an ID pointing to the cell specification it belongs to, and the `parent_id` of a `channel` result is an ID pointing to its cycler. You can use `parent_id` to navigate or query the parent directly. ### Finding cyclers and channels To restrict a query to hardware only, pass `entity_types=cycler` and `entity_types=channel`. Both are project-scoped, so `project_id` narrows the results to a single project when provided: ```bash theme={null} curl -G "https://api.ionworks.com/search" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ --data-urlencode "q=maccor" \ --data-urlencode "entity_types=cycler" \ --data-urlencode "entity_types=channel" ``` Each `channel` result carries the `cycler_id` of its parent cycler in `parent_id`, which you can use to look up the cycler directly. Search is useful when you remember a partial name (for example, a cell chemistry, a customer name, or a protocol keyword) but don't know which project or study contains it. Search uses server-side full-text indexing, so queries return fast even as your organization accumulates many resources. Indexes are maintained automatically — there is nothing to configure. # Analyses Source: https://docs.ionworks.com/data/analyses Store features extracted from a cell measurement — like ECM parameters from EIS or LLI/LAM points from RPT data — as parquet-backed analysis records An **analysis** captures features extracted from a single [cell measurement](/data/measurements) — for example, ECM parameters fitted to an EIS sweep, an LLI/LAM degradation point derived from an RPT, or a DCIR value computed from an HPPC pulse. Each analysis is one row tied to one parent measurement, plus a parquet file holding the extracted values. Use analyses when you want to persist the output of a post-processing step alongside the measurement it came from, so downstream tools (fitting, visualization, reporting) can find it later without re-running the extractor. ## When to use an analysis Reach for an analysis whenever you have derived, tabular results that: * Belong to exactly one measurement (the "source of truth" measurement). * You want to keep as structured columns rather than as a raw file attachment on a [file measurement](/data/measurements#file). * May be produced by different extractors over time — analyses are free-form on `analysis_type`, so you can add new kinds without a schema change. Typical examples: | `analysis_type` | Extracted from | Typical columns | | ------------------ | ---------------------------- | ------------------------------------------------ | | `ecm_from_eis` | EIS time-series measurement | `R0`, `R1`, `C1`, ... with units like `Ohm`, `F` | | `lam_lli_from_rpt` | RPT time-series measurement | `LAM_pe`, `LAM_ne`, `LLI` with unit `%` | | `dcir_from_hppc` | HPPC time-series measurement | `dcir_charge`, `dcir_discharge` at various SOCs | `analysis_type` is a free-form string — any non-empty value is accepted, so you can define your own extractor types freely. `ecm_from_eis`, `lam_lli_from_rpt`, and `dcir_from_hppc` are the advisory set the Studio UI surfaces by default. ## Anatomy of an analysis Every analysis record carries: | Field | Description | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `measurement_id` | ID of the parent cell measurement this analysis is derived from. | | `name` | User-provided name for the analysis. | | `analysis_type` | Kind of analysis (free-form string, e.g. `"ecm_from_eis"`). | | `columns` | List of `{name, unit, dtype?}` specs describing the parquet columns, for header preview in the UI. | | `metadata` | Loose JSON object — source RPT/cycle identity, extractor parameters, solver settings, etc. | | `notes` | Free-text description of how the analysis was performed. | | `source_pipeline_id` | Optional. ID of the pipeline run that produced this analysis. | | `source_simple_pipeline_id` | Optional. ID of the simple pipeline that produced this analysis. | | `source_analysis_id` | Optional. ID of another analysis this analysis was computed from. | | `source_label` | Optional. Free-text provenance note. | | `id`, `organization_id`, `created_by`, `created_at`, `updated_at` | Populated by the server. | At most **one** of the three `source_*_id` fields may be set — an analysis has a single upstream Ionworks record, or none. `source_label` is independent and can be set on its own. See [Tracking analysis provenance](#tracking-analysis-provenance) for how to use these fields. The tabular values themselves live in a parquet file stored in the `measurement-data` bucket alongside the parent measurement. Signed download URLs are minted on demand. Analyses are managed through the [Python API client](/api-client), which exposes them as `client.analysis`. `create()` accepts a DataFrame directly — the client serialises it to parquet and uploads it for you. ## Creating an analysis ```python theme={null} from ionworks import Ionworks, AnalysisType import polars as pl client = Ionworks() df = pl.DataFrame({"R0": [0.012], "R1": [0.008], "C1": [1.4]}) analysis = client.analysis.create( measurement_id="msmt_abc123", name="ECM fit at 25 °C, 50% SOC", analysis_type=AnalysisType.ECM_FROM_EIS, # or the string "ecm_from_eis" data=df, columns=[ {"name": "R0", "unit": "Ohm"}, {"name": "R1", "unit": "Ohm"}, {"name": "C1", "unit": "F"}, ], metadata={"soc_pct": 50, "temperature_degc": 25, "fit_rms": 0.0021}, notes="Fitted with Randles model, 1e-3 to 1e5 Hz sweep", ) print(analysis.id) ``` `analysis_type` is free-form — pass any string, or one of the `AnalysisType` StrEnum members (`ECM_FROM_EIS`, `LAM_LLI_FROM_RPT`, `DCIR_FROM_HPPC`) for the well-known values. ## Listing and fetching analyses Provide **exactly one** of `measurement_id` or `project_id`. Filter by `measurement_id` to list the analyses derived from a single measurement, or by `project_id` to list every analysis across the measurements in a project. Supplying both, or neither, is an error. ```python theme={null} # All analyses for a single measurement for a in client.analysis.list(measurement_id="msmt_abc123"): print(a.id, a.analysis_type, a.name) # All analyses across a project (limit is capped at 100) page = client.analysis.list(project_id="proj_xyz", limit=100, offset=0) print(page.total) # Single analysis record analysis = client.analysis.get("ana_...") ``` ## Downloading extracted-feature data The parquet file is not returned inline. `get_data()` fetches a signed URL and reads the parquet directly, returning a DataFrame in the [configured backend](/api-client#dataframe-backend): ```python theme={null} df = client.analysis.get_data("ana_...") print(df.head()) ``` Use `get_download_url()` if you'd rather download the raw parquet yourself. The signed URL is valid for 5 minutes: ```python theme={null} url = client.analysis.get_download_url("ana_...") # valid for 5 minutes ``` ## Updating metadata `update()` changes row-level fields only — it does **not** replace the parquet. Any subset of `name`, `analysis_type`, `columns`, `metadata`, `notes`, `source_pipeline_id`, `source_simple_pipeline_id`, `source_analysis_id`, and `source_label` may be supplied. See [Tracking analysis provenance](#tracking-analysis-provenance) for the source fields. ```python theme={null} client.analysis.update("ana_...", { "name": "ECM fit at 25 °C, 50% SOC (v2)", "metadata": {"soc_pct": 50, "temperature_degc": 25, "fit_rms": 0.0018}, }) ``` To replace the parquet itself, delete the analysis and re-create it. ## Deleting an analysis Removes the row and its parquet file. ```python theme={null} client.analysis.delete("ana_...") ``` ## Browsing analyses in Studio A measurement's detail page has an **Analyses** section that lists every analysis derived from that measurement. Each entry shows the analysis name, its `analysis_type`, and when it was created; click the analysis **name** to open the analysis detail page and its extracted-feature table. ## Viewing an analysis in Studio Every analysis has a detail page in Ionworks Studio that renders the parquet data as an interactive plot alongside its metadata, columns, and notes. Open it from the analyses list on the parent measurement, or navigate directly if you have the URL. ### Breadcrumb trail The header shows the full lineage of the analysis so you can walk back up to any ancestor: **Cell specification → Cell instance → Measurement → Analysis** Each segment is a link. If lineage lookup fails for any reason, the trail falls back to a single **Measurement** link — the page still loads. ### Data preview plot The right-hand panel plots two numeric columns from the parquet against each other. Use the **X axis** and **Y axis** dropdowns above the plot to pick any pair whose values can be represented safely as JavaScript numbers. Non-numeric columns (strings, booleans) and integer columns with values outside JavaScript's safe-integer range (large IDs or timestamps that stay `int64`) are not selectable. * Axis labels use the `name [unit]` format declared in the analysis's `columns` spec, so pick meaningful `unit` values when you `create()` an analysis. * The trace renders as `lines+markers` when the selected X column is monotonically non-decreasing, and as `markers` only otherwise — useful for scatter-style outputs like ECM parameter sweeps. * On first load the axes default to the first two numeric columns. ### Row limits The preview is capped to keep the browser responsive: * The table shows the first **100 rows** of the parquet. * The plot materializes at most the first **10,000 rows**. * A caption below the plot reports the true row count and, when the file is larger than the plot cap, notes that the plot is truncated. To work with the full dataset, use **Download parquet** in the header (which mints a fresh signed URL) or fetch the data from Python: ```python theme={null} df = client.analysis.get_data("ana_...") ``` ### Source measurement link The **View source measurement** button on the left column jumps straight to the parent measurement's detail view — the same target as the measurement segment of the breadcrumb. These cross-links make it easy to trace a fitted ECM parameter or a degradation point back to the raw cycling data it was extracted from, and to see at a glance which measurements already have analyses attached. ## Tracking analysis provenance An analysis's `measurement_id` says which measurement it was extracted from. The `source_*` fields answer a different question: **which computation produced it?** — a pipeline run, a simple pipeline, or another analysis it was chained off — plus a free-text `source_label` for provenance that isn't a linkable Ionworks record. | Field | Description | | --------------------------- | -------------------------------------------------------- | | `source_pipeline_id` | The pipeline run whose output produced this analysis. | | `source_simple_pipeline_id` | The simple pipeline that produced this analysis. | | `source_analysis_id` | The upstream analysis this one was computed from. | | `source_label` | Free-text note (e.g. `"ECM refit with tighter bounds"`). | At most one of the three `source_*_id` fields may be set on a given analysis, and each referenced row must exist or the request is rejected; the check does not also verify that the row is visible to you, so a source ID for a record you can't otherwise see is set but only reads back as unresolved when someone tries to follow the link. `source_label` is independent — set it on its own when the source is not another Ionworks record. Set the fields at create time by passing them through `client.analysis.update()` after creating the analysis, or when driving the REST API directly by including them as `Form(...)` fields on `POST /analyses`: ```python theme={null} analysis = client.analysis.create( measurement_id="msmt_abc123", name="ECM fit at 25 °C, 50% SOC", analysis_type="ecm_from_eis", data=df, columns=[{"name": "R0", "unit": "Ohm"}], ) # Record which pipeline produced it client.analysis.update(analysis.id, { "source_pipeline_id": "pipe_xyz789", "source_label": "ECM refit with tighter bounds", }) ``` In Studio, the analysis detail page shows a **Source** row alongside the existing "View source measurement" button. When the source resolves to a pipeline or another analysis you have access to, the label is a link straight to its detail page. Datasets on the [materials page](/data/materials#tracking-dataset-provenance) use the same model, so you can follow a curve → the fit that produced it → the analysis that seeded the fit without leaving Studio. ## Analyses vs. file measurements Both attach data to a measurement, but they serve different purposes: | | Analysis | [File measurement](/data/measurements#file) | | ---------- | ------------------------------------------------------ | -------------------------------------------- | | Parent | One cell measurement | A cell instance directly | | Data shape | Tabular parquet with declared columns/units | Arbitrary files (images, PDFs, numpy arrays) | | Use for | Extracted features, fit parameters, degradation points | SEM photos, X-ray CTs, post-mortem docs | Reach for an analysis when the output is structured tabular data derived from an existing measurement. Reach for a file measurement when the data is a standalone artifact tied to the cell itself. ## Next steps Time series, properties, and file measurement types — the parents of every analysis. List, filter, and retrieve measurements together with their analyses. # Data format Source: https://docs.ionworks.com/data/format Recognized time series columns, units, current sign convention, EIS columns, and quantity format for battery cycling data Ionworks Studio uses a standardized format for battery cycling data. This page is the reference for recognized columns, the quantity format for numeric values, and the sign conventions Ionworks assumes on upload. For turning raw cycler files into this format, see [preparing data](/data/preparing-data). For the upload workflow, see [uploading data](/data/uploading). ## Time series columns The time series DataFrame contains high-resolution measurements from your cycling experiment. You can include any columns in your data; the columns below are **recognized** by the automatic step labeling system. | Column | Type | Description | | -------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Time [s]` | float | Cumulative time in seconds (not reset per cycle) | | `Voltage [V]` | float | Cell voltage | | `Current [A]` | float | Current (positive = discharge, negative = charge) | | `Power [W]` | float | Instantaneous power. If not supplied, it is computed as `Voltage [V] × Current [A]` during processing and stored on the measurement, so reads and plots get it without the source file providing it. Derivation requires both `Voltage [V]` and `Current [A]`; if either is missing, no power column is added. | | `Cycle count` | int | Cumulative cycle number (0-indexed) | | `Step count` | int | Cumulative step number across all cycles (0-indexed) | | `Temperature [degC]` | float | Cell temperature | | `Cycle from cycler` | int | Cycle number as reported by cycler | | `Step from cycler` | int | Step number as reported by cycler | | `Discharge capacity [A.h]` | float | Cumulative discharge capacity | | `Charge capacity [A.h]` | float | Cumulative charge capacity | | `Discharge energy [W.h]` | float | Cumulative discharge energy | | `Charge energy [W.h]` | float | Cumulative charge energy | **Time must be cumulative** across cycles. If your cycler resets time to zero at the start of each cycle, convert it to cumulative time during processing. See [preparing data — time not cumulative](/data/preparing-data#time-not-cumulative). ### Cycler execution columns The Maccor reader also carries through the columns recording the cycler's own execution. They are **not** inputs to step labeling — like any custom column, they are stored for inspection and plotting only. | Column | Type | Description | | ---------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `Mode from cycler` | str | Maccor `MD`: one letter per row for the cycler's own mode. Distinguishes step-transition markers from genuine zero-current rest samples. | | `End status from cycler` | int | Maccor `ES`: the per-step end code, i.e. why the step ended. | | `Loop N from cycler` | int | Maccor `Loop1`..`Loop4`: the cycler's loop counters. | | `VARn from cycler` / `FLAGn from cycler` | float | Maccor `SetVar` registers. `VAR1` commonly holds the per-cell capacity the procedure used as its C-rate basis. | `read.measurement_details` reduces the time series to the recognized columns above, so these are dropped before upload unless you pass `keep_only_required_columns=False`. ### Custom columns You can add any additional columns to your time series data. Custom columns are stored and available for visualization but aren't used by the automatic step labeling system. ## Current sign convention Ionworks uses a consistent sign convention throughout: * **Positive current = discharge** * **Negative current = charge** The validator rejects data that appears to use the opposite convention or whose convention cannot be determined (all values have the same sign). If your cycler uses a different convention, see [preparing data — sign conventions](/data/preparing-data#incorrect-current-sign-convention) for conversion helpers. ## EIS and impedance data If your measurement includes electrochemical impedance spectroscopy (EIS) data, the following columns are recognized: | Column | Type | Description | | ---------------- | ----- | -------------------------------------------------------------- | | `Frequency [Hz]` | float | Excitation frequency | | `Z_Re [Ohm]` | float | Real part of impedance | | `Z_Im [Ohm]` | float | Imaginary part of impedance (negative for capacitive behavior) | | `Z_Mod [Ohm]` | float | Impedance magnitude | | `Z_Phase [deg]` | float | Impedance phase angle | **Sign convention:** `Z_Im [Ohm]` stores the raw imaginary part of impedance, which is typically negative for capacitive behavior. The Nyquist plot in Ionworks Studio automatically negates this value to display `-Z_Im [Ohm]` on the y-axis, following the standard electrochemistry convention. File readers (e.g., BioLogic, Gamry) handle this conversion automatically. ### Cartesian / polar derivation When your data includes `Z_Mod [Ohm]` and `Z_Phase [deg]` but is missing `Z_Re [Ohm]` or `Z_Im [Ohm]`, Ionworks automatically derives the missing components: * `Z_Re = Z_Mod * cos(Z_Phase * π/180)` * `Z_Im = Z_Mod * sin(Z_Phase * π/180)` This means you can upload impedance data in either Cartesian (`Z_Re`, `Z_Im`) or polar (`Z_Mod`, `Z_Phase`) form and Ionworks will ensure all four columns are available for analysis. Existing columns are never overwritten. ## Quantity format All numeric values with units use the format `{"value": , "unit": ""}`. For example: ```json theme={null} {"value": 2.0, "unit": "A.h"} ``` This format applies to cell specification ratings (`capacity`, `voltage_min`, etc.), cell instance `measured_properties`, and properties measurements. Plain strings and numbers without units are also accepted where appropriate. ### Unit notation Units are stored in **PyBaMM notation**: `.`-separated atoms with optional signed integer exponents, no `*`, `/`, or `**`. For example: | Quantity | PyBaMM notation | | --------------- | --------------------------------------------- | | Capacity | `A.h` | | Areal loading | `mg.cm-2` | | Specific energy | `W.h.kg-1` | | Concentration | `mol.m-3` | | Conductivity | `S.m-1` | | Single units | `V`, `A`, `s`, `degC`, `ohm`, `mm`, `percent` | **Pint-style strings are also accepted** on input and are normalized to PyBaMM notation when stored. The following pairs are equivalent on submit: | Pint input | Stored as | | ---------- | ---------- | | `A*h` | `A.h` | | `mg/cm**2` | `mg.cm-2` | | `W*h/kg` | `W.h.kg-1` | | `mol/m**3` | `mol.m-3` | | `S/m` | `S.m-1` | This means existing payloads that use Pint syntax continue to work, but values returned from the API will be in PyBaMM notation. Prefer PyBaMM notation in new code so what you send matches what you read back. ```python theme={null} # Both of these are accepted; the stored unit is "A.h" in either case. client.cell_spec.update( cell_spec.id, ratings={"capacity": {"value": 2.0, "unit": "A.h"}}, ) client.cell_spec.update( cell_spec.id, ratings={"capacity": {"value": 2.0, "unit": "A*h"}}, ) ``` ## Step summaries Step summaries contain aggregated metrics for each step in the experiment. They enable efficient filtering and cycle-level analysis. Step summaries are **automatically generated** from your time series data during upload if you don't provide them yourself. Automatically generated step summaries include columns like `step_type` (inferred as Rest, CC charge, CC discharge, CV, etc.), `duration_s`, voltage statistics (`start_voltage_v`, `end_voltage_v`, `mean_voltage_v`), current statistics, and capacity/energy totals for each step. ## Next steps Convert cycler files into this format with the ionworksdata library. Upload formatted data as cell specs, instances, and measurements. The three measurement types and their fields. Explore uploaded data with the interactive viewer. # Materials & property datasets Source: https://docs.ionworks.com/data/materials Manage electrode, electrolyte, and other materials and attach measured property datasets like OCP, diffusivity, and conductivity curves ## What are materials? A **Material** is a reusable record describing a physical material used in a [cell specification](/core-concepts/cells) — for example, an NMC811 cathode powder, a graphite anode, or an LP57 electrolyte. Materials and their wrapping **cell components** (anode, cathode, electrolyte, separator, case) are **always scoped to a project**. Every material and component belongs to exactly one project, and cell specifications only ever reference materials and components in their own project. Two projects in the same organization each get their own copy of the "same" material — same name, manufacturer, and product ID — so property datasets and cell references stay cleanly separated per project. Reusing a material across projects means creating a matching material in each project. Listing and creating materials require a `project_id`, but reading, updating, or deleting a single material by ID is authorized at the organization level — any project member with access to the material's organization can look up or modify a material by ID, including one that belongs to a different project within that organization. Any older materials that predated per-project scoping have been split into per-project copies, and the shared "System" material library (Graphite, NMC, LFP, …) is no longer read from at spec creation time — the library definitions are cloned into your own project instead. You will not see cross-project material sharing anywhere in the app. Each material can have any number of **property datasets** attached to it. A property dataset is a tabular measurement (CSV or parquet) of one or more physical properties as a function of one or more independent variables — for example, electrolyte conductivity vs. concentration, or anode OCP vs. stoichiometry. Property datasets belong to the same project as their parent material. Property datasets are stored as data — they are separate from [parameter interpolants](/build/interpolants), which embed lookup tables directly into a parameterized model. Use property datasets to organize and share raw measurements, then turn them into interpolants when you are ready to use them in a simulation. ## When to use materials Use materials when you want to: * Keep a single source of truth for properties of a material used across multiple cells (e.g. the same electrolyte in several cell builds). * Store raw measurements (OCP, diffusivity, conductivity, transference number, …) alongside the material they were measured on. * Compare multiple datasets for the same property — for example, OCP curves measured at different temperatures or by different labs. * Track provenance: who uploaded a dataset, when, and from which raw file. ## Managing materials in the UI Each project has a **Materials** section in the left navigation. From there you can: * **Create a material** — give it a name, and optionally a manufacturer and product ID. * **Open a material** — view its property datasets and metadata. * **Edit or delete** a material from the row actions menu. Materials created from the cell specification editor inherit the project of the cell spec, so you rarely need to pick a project explicitly — the material is created in the same project as the cell using it. ### Uploading a property dataset From a material's detail page, click **Upload property dataset** and: 1. **Pick a file** — CSV or parquet. CSVs may include or omit a header row. When you select a file, the dataset name is prefilled from the file name; edit it if you want something different. 2. **Review the dataset name** — prefilled from the file name in step 1; change it if you want something different (e.g. `Conductivity at 25 °C`). 3. **Declare columns** — every column detected in the file is listed in order. For each one, provide: * **Name** — the display name stored in the processed dataset (e.g. `c_e`). Every listed column must have a name before you can submit — drop the row for any column you don't want to import, or name it and ignore it later. Trailing empty columns (a common Excel "trailing comma" artifact) are trimmed automatically. * **Unit** — the physical unit (e.g. `mol/L`, `S/m`). Leave blank for dimensionless quantities. * **Source column** — the column in the uploaded file the values come from. For headerless CSVs this is a position; for files with a header you can pick by name. 4. **Submit.** The file is parsed, every value is coerced to a floating-point number (non-numeric cells become NaN), and both the processed parquet and the original raw file are stored. After upload, the dataset appears in the material's property list with the number of rows and any NaN counts per column, so you can spot parsing issues quickly. ### Plotting a dataset Click a dataset to open the **plot dialog**. You can: * Pick the x and y columns from the dataset. The legend shows each y column with its unit (e.g. `kappa (S/m)`) so dual-axis plots are easy to read. * Zoom and pan; the plot dynamically downsamples and re-fetches points for the visible range so large datasets stay responsive. * Download the processed parquet or the original raw file from the actions menu. ### Editing a dataset The **Edit** action on a dataset lets you: * Rename the dataset. * Re-declare column names and units. When columns change, the stored parquet is rebuilt from the preserved original file using the new specs — you do not need to re-upload. * Replace the data file entirely while keeping the same dataset ID and metadata. Other records that reference the dataset stay linked. Each data-changing edit bumps the dataset's `data_version`, so downstream consumers can detect when a cached result is stale. ## Tracking dataset provenance Every property dataset can record **where it came from** so you can trace a curve on a plot back to the pipeline, fit, or analysis that produced it — or to the paper, lab notebook, or vendor sheet it was digitized from. Provenance is captured with four optional fields: | Field | Description | | --------------------------- | ----------------------------------------------------------------------------- | | `source_pipeline_id` | The pipeline run whose output produced this dataset. | | `source_simple_pipeline_id` | The simple pipeline that produced this dataset. | | `source_analysis_id` | The [analysis](/data/analyses) this dataset was derived from. | | `source_label` | Free-text note (e.g. `"Smith et al. 2023, Fig. 3"` or `"Manually uploaded"`). | **At most one** of the three `source_*_id` fields may be set on a given dataset — a dataset has a single upstream Ionworks record, or none. The referenced row must exist, or the request is rejected; the check does not also verify that the row is visible to you (e.g. that it belongs to a project or organization you can access), so a source ID for a record you can't otherwise see is set but only reads back as unresolved when someone tries to follow the link. `source_label` is independent and can be set on its own — use it when the source is not a linkable Ionworks record. The Source column on a material's dataset grid renders `source_label` when set and otherwise the kind of source; when the source resolves to a pipeline or analysis you can see in Studio, the label is a link straight to the source's detail page. ### Set source when uploading or editing in the UI The **Upload property dataset** and **Edit** dialogs include a **Source** section: 1. Pick a **source kind** — Pipeline, Simple pipeline, Analysis, or leave blank for "no linkable source". 2. Paste the corresponding ID. Switching the kind clears any previously entered ID so you don't end up with more than one source set. 3. Optionally add a **source label** — a short free-text note that shows up in the Source column and on the dataset detail view. ### Set source via the REST API Pass any subset of the source fields when creating or updating a dataset. This example records that a dataset was produced by a pipeline run and adds a free-text label: ```bash theme={null} curl -X POST "$IONWORKS_URL/material_property_datasets" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ -F "file=@conductivity.csv" \ -F "material_id=$MATERIAL_ID" \ -F "project_id=$PROJECT_ID" \ -F "name=Conductivity at 25 °C" \ -F 'columns=[{"name":"c_e","unit":"mol.L-1","source_column_index":0},{"name":"kappa","unit":"S.m-1","source_column_index":1}]' \ -F "source_pipeline_id=$PIPELINE_ID" \ -F "source_label=Fitted from 25 °C EIS sweep" ``` To change or clear provenance on an existing dataset, `PATCH` the fields you want to update; send `null` to clear a source ID or label: ```bash theme={null} curl -X PATCH "$IONWORKS_URL/material_property_datasets/$DATASET_ID" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"source_pipeline_id": null, "source_analysis_id": "'$ANALYSIS_ID'"}' ``` The `MaterialPropertyDataset` records returned by the Python client and the REST API expose the same four fields, so you can read a dataset's provenance from anywhere it appears: ```python theme={null} dataset = client.material_property_dataset.get("mpd_def456") print(dataset.source_pipeline_id, dataset.source_analysis_id, dataset.source_label) ``` ## Python client The [Python API client](/api-client) exposes materials and their property datasets as two sub-clients: * `client.material` — list and retrieve materials. * `client.material_property_dataset` — list, retrieve, and download property datasets, and create, edit, or delete them. `client.material` is read-only; to create or modify a material itself, use the UI or the [REST API](#rest-api). Property datasets can be managed from the client — see [Creating and editing property datasets](#creating-and-editing-property-datasets). ### Listing and retrieving materials ```python theme={null} from ionworks import Ionworks client = Ionworks() # List materials in a specific project materials = client.material.list(project_id="proj_xyz789", limit=50) for m in materials: print(m.id, m.name, m.manufacturer, m.product_id, m.project_id) # Retrieve a single material by ID material = client.material.get("mat_abc123") ``` `project_id` is required when listing materials — every material is scoped to a project, and the list endpoint returns the materials owned by that project. Each material record also carries its `project_id`. When the client has a default project configured, `project_id` falls back to it; without either, `list()` raises a `ValueError`. Filtering, ordering, and pagination all happen server-side, so you don't need to fetch a page and match locally: ```python theme={null} # Exact match on name nmc = next(iter(client.material.list(name_exact="NMC811")), None) # Case-insensitive partial match, newest first graphites = client.material.list( name="ilike.%graphite%", order_by="created_at", order="desc", ) ``` Text filters (`name`, `manufacturer`, `product_id`) take a bare value for an exact match, or an operator prefix such as `"ilike.%graphite%"` for a partial one. `name_exact` is shorthand for an exact match and cannot be combined with `name`. Time filters `created_at` and `updated_at` accept the same operator form (e.g. `"gte.2026-01-01"`) and have `_gt` / `_lt` variants for range bounds. Sort with `order_by` (`name`, `manufacturer`, `created_at`, or `updated_at`) and `order` (`asc` or `desc`). ### Finding which cells use a material To go the other way — from a material to the [cell specifications](/core-concepts/cells) that use it — filter `client.cell_spec.list` by material. The match happens in the database across all five component slots, so don't fetch every spec and inspect its components yourself. ```python theme={null} # Every spec in the project that uses this material, in any slot specs = client.cell_spec.list(material_id="mat_abc123", project_id="proj_xyz789") # Several materials at once — matches a spec using any of them specs = client.cell_spec.list( material_id=["mat_abc123", "mat_def456"], project_id="proj_xyz789" ) # Restrict the search to one slot specs = client.cell_spec.list(cathode_material_id="mat_abc123", project_id="proj_xyz789") ``` A per-slot parameter is available for each slot — `anode_material_id`, `cathode_material_id`, `electrolyte_material_id`, `separator_material_id`, and `case_material_id`. Pass `exclude_cell_spec_id` to drop one spec from the results. A material reverse lookup is project-scoped, so it needs a `project_id` — it falls back to the one set on the client or `IONWORKS_PROJECT_ID`, and raises if neither is set. Because the filtering happens in the database, `total` counts every matching spec rather than the rows on the current page, so page through the results when there may be more than one page. ### Finding cells related by material Given one spec, `related_specs` finds the others that share a component material with it — useful for "what else did we build with this cathode?" ```python theme={null} related = client.cell_spec.related_specs("spec_abc123") # Compare slot-for-slot instead: same anode, or same cathode related = client.cell_spec.related_specs("spec_abc123", slots=["anode", "cathode"]) ``` By default any of the source spec's slot materials matches in any slot. Passing `slots` compares each named slot only against the same slot on the source spec. The source spec is excluded from the results unless you pass `exclude_self=False`, and `include_components=True` returns each spec with its nested component and material data. ### Listing property datasets for a material ```python theme={null} datasets = client.material_property_dataset.list( material_id="mat_abc123", project_id="proj_xyz789", # optional: only datasets in this project limit=100, ) for d in datasets: print(d.id, d.name, d.data_version, [c.name for c in d.columns]) ``` Each `MaterialPropertyDataset` exposes its `columns` (a list of `ColumnSpec` records with `name`, `unit`, and `source_column_index`), the `data_version` that bumps on every edit, and per-column `nan_counts`. ### Downloading dataset values `get_data()` downloads the full dataset and returns it as a DataFrame in the configured [DataFrame backend](/api-client#dataframe-backend): ```python theme={null} dataset = client.material_property_dataset.get("mpd_def456") print(dataset.name, dataset.data_version) # Map column name to physical unit, e.g. {"c_e": "mol/L", "kappa": "S/m"} units = {col.name: col.unit for col in dataset.columns} # Pull all rows as a DataFrame df = client.material_property_dataset.get_data("mpd_def456") df.head() ``` ### Creating and editing property datasets `create()` uploads tabular data — a polars or pandas DataFrame, or a column-name-to-values dict — as a new dataset. Values are stored as floats, with any non-parseable cell recorded as NaN: ```python theme={null} import polars as pl df = pl.DataFrame({"c_e [mol/L]": [0.1, 0.5, 1.0], "kappa [S/m]": [0.3, 0.9, 1.2]}) dataset = client.material_property_dataset.create( material_id="mat_abc123", name="Conductivity vs concentration", data=df, ) ``` Each column's display name and unit come from its `columns` specs. When `columns` is omitted they are inferred from a trailing `[unit]` in the column name, as above; pass explicit specs to control the units directly. To edit an existing dataset: ```python theme={null} # Swap the stored file, keeping the existing column specs client.material_property_dataset.replace_file("mpd_def456", data=new_df) # Patch metadata only client.material_property_dataset.update("mpd_def456", name="Conductivity (revised)") # Remove it client.material_property_dataset.delete("mpd_def456") ``` `replace_file()` never infers columns, so it cannot silently erase stored units. When you omit `columns` it checks that the replacement's column layout matches the stored specs and raises a `ValueError` on a mismatch rather than mislabelling the data. Pass `columns` explicitly to change the specs along with the file. `get_download_url(dataset_id, kind="parquet")` returns a short-lived signed URL for the processed parquet; pass `kind="original"` for the file as uploaded. ### Using a stored scalar as a fit prior A "Scalar parameters" dataset holds a single row, so a cell in it is one measured value. `to_prior()` centres a fit prior on that value, ready to drop into a `DataFit`'s `priors` mapping — so a measurement feeds a fit without being retyped as a literal: ```python theme={null} import ionworks_schema as iws prior = client.material_property_dataset.to_prior( "mpd_def456", column="particle radius", parameter_name="Negative particle radius [m]", rel_std=0.1, # 10% of the stored value ) fit = iws.DataFit(objectives=..., priors={prior.name: prior}) ``` `column` names the dataset column; `parameter_name` is the fit parameter the prior applies to, which is usually a different, pipeline-facing name. A scalar carries no spread of its own, so you must supply the width — exactly one of `std` (absolute) or `rel_std` (a fraction of the value). For a strictly positive parameter spanning orders of magnitude, such as a diffusivity or conductivity, prefer a lognormal: ```python theme={null} prior = client.material_property_dataset.to_prior( "mpd_def456", column="diffusion coefficient", parameter_name="Negative particle diffusivity [m2.s-1]", rel_std=0.5, distribution="lognormal", ) ``` Pass `regularizer_weight` to scale the prior's contribution to the fit cost; it defaults to 1.0. See [regularization](/pipelines/data-fitting/regularization) for how priors enter the cost. A dataset with more than one row raises rather than silently using its first point — pass `row=` to pick one deliberately. A missing, non-numeric, or non-finite value raises too, as does a non-positive value under `distribution="lognormal"`. ## REST API Material property datasets are managed under `/material_property_datasets`. Materials themselves are managed under `/materials`. ### Upload a dataset `POST /material_property_datasets` accepts a multipart form: | Field | Description | | --------------------------- | ------------------------------------------------------------------------------------------ | | `file` | The CSV or parquet file to upload. | | `material_id` | ID of the parent material. | | `project_id` | ID of the project this dataset is scoped to. | | `name` | Human-readable dataset name. | | `columns` | JSON array of column specs (see below). | | `no_header` | `true` if the CSV has no header row. Defaults to `false`. | | `source_pipeline_id` | Optional. ID of the pipeline run this dataset was produced by. | | `source_simple_pipeline_id` | Optional. ID of the simple pipeline this dataset was produced by. | | `source_analysis_id` | Optional. ID of the [analysis](/data/analyses) this dataset was derived from. | | `source_label` | Optional. Free-text provenance note (e.g. a paper reference or manual-upload description). | At most **one** of the three `source_*_id` fields may be set on a given dataset. Set `source_label` alone when the provenance is not a linkable Ionworks record — for example, "manually digitized from Smith et al. 2023". See [Tracking dataset provenance](#tracking-dataset-provenance) for the full model. Each column spec is an object: ```json theme={null} { "name": "c_e", "unit": "mol.L-1", "source_column_index": 0 } ``` `source_column_index` is the 0-based position of the column in the uploaded file. It is required even when the file has a header row — names are matched by position, then renamed to the `name` you provide. Example upload with `curl`: ```bash theme={null} curl -X POST "$IONWORKS_URL/material_property_datasets" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ -F "file=@conductivity.csv" \ -F "material_id=$MATERIAL_ID" \ -F "project_id=$PROJECT_ID" \ -F "name=Conductivity at 25 °C" \ -F 'columns=[ {"name": "c_e", "unit": "mol.L-1", "source_column_index": 0}, {"name": "kappa", "unit": "S.m-1", "source_column_index": 1} ]' ``` The response is the new dataset record, including its `id`, `storage_path`, `data_version`, and per-column `nan_counts`. ### List datasets for a material ```bash theme={null} curl "$IONWORKS_URL/material_property_datasets?material_id=$MATERIAL_ID&project_id=$PROJECT_ID&limit=100&offset=0" \ -H "Authorization: Bearer $IONWORKS_API_KEY" ``` Returns a paginated list of dataset records. ### Fetch dataset values as JSON ```bash theme={null} curl "$IONWORKS_URL/material_property_datasets/$DATASET_ID/data?max_points=500&x_column=c_e&x_min=0&x_max=2" \ -H "Authorization: Bearer $IONWORKS_API_KEY" ``` Returns the dataset as a column-major JSON object: ```json theme={null} { "c_e": [0.5, 1.0, 1.5, 2.0], "kappa": [0.42, 0.71, 0.89, 0.95] } ``` `max_points` downsamples uniformly so large datasets remain responsive to plot. `x_column`, `x_min`, and `x_max` restrict the response to a range of one column — useful for zooming charts. ### Download the underlying file Use `GET /material_property_datasets/{id}/file` to redirect to a short-lived signed URL for the file, or `GET /material_property_datasets/{id}/download-url` to receive the URL as JSON (handy when you want to open it from the browser). Pass `?kind=parquet` (default) to download the processed parquet, or `?kind=original` to download the raw file you uploaded. ### Update metadata, replace the file, or delete | Endpoint | Description | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `PATCH /material_property_datasets/{id}` | Rename the dataset and/or re-declare its columns. When columns change, the parquet is rebuilt from the preserved original file. | | `PATCH /material_property_datasets/{id}/file` | Replace the data file. Optionally update `name`, `columns`, and `no_header` in the same request. | | `DELETE /material_property_datasets/{id}` | Delete the dataset and its stored files. | ## Related * [Cells](/core-concepts/cells) — materials are referenced from the anode, cathode, electrolyte, and separator components of a cell specification. * [Parameter interpolants](/build/interpolants) — turn measured property data into lookup-table parameters inside a parameterized model. * [Electrolyte transport from a dataset](/build/parameterize/direct-entries#building-electrolyte-transport-from-a-material-dataset) — build concentration-dependent electrolyte transport parameters from a property dataset and drop them into a pipeline. * [Data overview](/data/overview) — how experimental data is organized in Ionworks Studio. # Measurements Source: https://docs.ionworks.com/data/measurements The three measurement types — time series, properties, and file — with fields, creation examples, and when to use each A **cell measurement** represents a single experiment or test performed on a [cell instance](/core-concepts/cells). Every measurement has a `measurement_type` that determines what data it stores and how you create it. ## Measurement types | Type | Stores | Typical use | | ------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `time_series` | High-resolution cycling data with voltage, current, and time columns. Includes auto-generated step and cycle summaries. | Battery cycling tests, EIS measurements, charge/discharge experiments | | `properties` | Key-value pairs with optional units. No file upload. | Manual measurements like thickness, weight, internal resistance | | `file` | One or more uploaded files (images, PDFs, numpy arrays, etc.) attached to the record. | Microscopy images, SEM photos, post-mortem analysis documents | ## Common fields Every measurement — regardless of type — accepts these optional metadata fields: | Field | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | Name for the measurement | | `protocol_id` | The [protocol](/simulate/protocols) (experiment template) this test ran. Must belong to the same project as the measurement. Preferred over the free-form `protocol` dict — see [recording what a measurement ran](#recording-what-a-measurement-ran). | | `protocol.name` | Protocol description (free-form; omit when `protocol_id` is set) | | `protocol.ambient_temperature_degc` | Test temperature | | `test_setup` | `cycler`, `operator`, `lab`, `channel_number` | | `start_time` | ISO 8601 start time, e.g. `"2026-03-15T09:30:00Z"` | | `channel_id` | ID of the physical [channel](/operate/lab-view) the test ran on. Must belong to the same project as the measurement. | | `program_id` | Optional [program](/operate/planned-measurements#programs) this test belongs to. Copied automatically from the linked planned measurement, or set directly on the measurement. Must reference a program in the same organization. | | `estimated_end_time` | ISO 8601 estimate of when the test will finish. Optional. Shown on channel cards in the [Lab view](/operate/lab-view) while the test is running so you can see when a channel is expected to free up. | | `notes` | Free-form notes about the test | ## Recording what a measurement ran Two fields describe the test, and they are not interchangeable: | Field | What it is | | ------------- | ---------------------------------------------------------- | | `protocol_id` | A reference to a real protocol — the executable definition | | `protocol` | A free-form dict of the conditions the run happened under | **Prefer `protocol_id`.** It points at a definition the platform can act on: resolve to a name, link to in the app, simulate, or convert to a cycler file. The dict is unvalidated text, so two runs of the same test can spell it differently and nothing reconciles them. **Use the `protocol` dict when you have no full protocol.** That is common and not a failure: data imported from a cycler export often records "1C discharge at 25 °C" and nothing reconstructable into an executable definition. Recording what you know beats dropping it. Setting both is the best record — `protocol_id` for what the run was *supposed* to execute, the dict for the conditions it *actually* ran under. One protocol serves many runs at different temperatures, and only the dict captures which. ```python theme={null} client.cell_measurement.create( instance_id, { "measurement": { "name": "udds_25degc", "protocol_id": "experiment-template-id", "protocol": {"ambient_temperature_degc": 25.0, "c_rate": 1.0}, }, "time_series": df, }, ) ``` When you set `protocol_id`, do not put a `name` in the `protocol` dict. The protocol already has one, the two can disagree, and the app suppresses the dict's `name` once `protocol_id` is set. `protocol_id` can also be attached later, once the protocol a run executed is known, via `client.cell_measurement.update()`. ## Time series Time series is the default measurement type. It carries high-resolution cycling data and auto-generates step and cycle summaries on upload. The time series DataFrame must follow the [data format](/data/format) — recognized columns include `Time [s]`, `Voltage [V]`, `Current [A]`, `Step count`, and `Cycle count`. ```python theme={null} import pandas as pd time_series = pd.DataFrame({ "Time [s]": [0, 1, 2, 3, 4, 5], "Voltage [V]": [3.0, 3.2, 3.5, 3.8, 4.0, 4.2], "Current [A]": [0.002, 0.002, 0.002, 0.002, 0.002, 0.002], "Step count": [0, 0, 0, 1, 1, 1], "Cycle count": [0, 0, 0, 0, 0, 0], }) bundle = client.cell_measurement.create( cell_instance.id, {"measurement": {"name": "Formation Cycle 1"}, "time_series": time_series}, ) ``` `create()` returns a `MeasurementBundle` containing the measurement record plus metadata like `steps_created`. See [uploading data](/data/uploading) for the full upload workflow — protocol and test-setup metadata, on-machine validation, and idempotent `create_or_get` — and [reading data](/data/reading) to fetch a measurement back with its full time series, steps, and cycles. ## Properties Properties measurements store key-value pairs directly in the record — no file upload. Use them for manual or one-off measurements like thickness, weight, or internal resistance. **Properties-specific fields:** | Field | Description | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `properties` | Key-value pairs. Use the [quantity format](/data/format#quantity-format) for numeric values. Plain strings and numbers are also allowed. | ```python theme={null} measurement = client.cell_measurement.create_properties( cell_instance.id, name="Post-formation properties", properties={ "thickness": {"value": 0.52, "unit": "mm"}, "weight": {"value": 45.3, "unit": "g"}, "internal_resistance": {"value": 12.5, "unit": "mOhm"}, "visual_condition": "No visible damage", }, notes="Measured after formation cycling", ) ``` You can include any of the common metadata fields (`protocol`, `test_setup`, `start_time`, `notes`) alongside the properties. `create_properties` returns a `Measurement` directly, not a `MeasurementBundle`. Properties measurements have no steps or file uploads, so the bundle wrapper isn't needed. ## File File measurements attach files (images, PDFs, numpy arrays, or any other file type) to a measurement record. Useful for microscopy images, SEM photos, X-ray CT scans, or post-mortem analysis documents. **File-specific fields:** | Field | Description | | ----------------- | --------------------------------------------------------------------------------------------- | | `filepaths` | List of local file paths to upload | | `validate_images` | When `True`, validates file headers match the extension before upload | | `file_metadata` | Populated automatically by the server after upload (MIME types, dimensions, etc.) — read-only | ```python theme={null} bundle = client.cell_measurement.create_file( cell_instance.id, name="Post-cycling SEM images", filepaths=[ "images/anode_surface_1000x.png", "images/cathode_cross_section_500x.png", ], notes="SEM images taken after 500 cycles", ) ``` To enable client-side image validation: ```python theme={null} bundle = client.cell_measurement.create_file( cell_instance.id, name="Electrode images", filepaths=["photo.png", "scan.tiff"], validate_images=True, ) ``` To access files from an existing file measurement: ```python theme={null} # List filenames in the measurement filenames = client.cell_measurement.list_files(measurement_id) # Download file contents directly files = client.cell_measurement.download_files(measurement_id) for filename, content in files.items(): print(f"{filename}: {len(content)} bytes") ``` File measurements use a signed URL upload flow internally. The client handles the multi-step process (initiate, upload, confirm) automatically. ### Previewing files in Studio When you open a file measurement in Studio, attached files are shown as a gallery beneath the measurement details: * **Images** (`.png`, `.jpg`, `.webp`, etc.) render as inline thumbnails. Click a thumbnail to open it in a full-screen lightbox. * **Non-image files** (PDFs, numpy arrays, CSVs, etc.) appear as cards with a download button. This makes it easy to review SEM photos, X-ray CT scans, and other microscopy images directly in the browser without downloading them first. ## Next steps End-to-end upload workflow: specs, instances, and measurements. List, filter, and retrieve measurements with their full data. # Data overview Source: https://docs.ionworks.com/data/overview Organize, upload, and explore experimental battery cycling data in Ionworks Studio Ionworks Studio provides an end-to-end toolkit for experimental battery data — from ingesting raw cycler files, to organizing measurements against cell specifications, to exploring results in the browser or via the Python API. ## Data hierarchy Your experimental data is organized hierarchically within your organization: ``` Organization └── Cell Specification (blueprint for a cell type) └── Cell Instance (specific physical cell) └── Cell Measurement ├── Time series (high-resolution cycling data with steps and cycles) ├── Properties (key-value measurements like thickness or weight) └── File (images, PDFs, or other attached files) ``` The blueprint defining cell properties — materials, capacity, voltage limits. Shared across cells with identical specifications. A specific physical cell you're testing. Each instance has a unique name within its cell specification. A single experiment or test run on a cell instance. Each measurement has a type that determines what data it stores. Raw time-series data and computed summaries stored in optimized formats for fast visualization and analysis. ## Measurement types Every cell measurement has a `measurement_type` — `time_series`, `properties`, or `file` — that determines what data it stores. See [measurements](/data/measurements) for the full table of types with their fields, creation examples, and when to use each. ## Data workflow Convert raw cycler files into the Ionworks format using the [`ionworksdata`](https://data.docs.ionworks.com/) library. See [preparing data](/data/preparing-data). Create cell specs, instances, and measurements via the Python API. See [uploading data](/data/uploading). Retrieve data back, filter, and plot with the Python client — see [reading data](/data/reading) — or explore interactively in the browser via [visualizing data](/data/visualizing). ## Browsing data tables When you navigate to a cell specification or cell instance in Ionworks Studio, data is displayed in sortable tables. Click any column header to sort — once for ascending, again for descending, a third time to clear. This works across cell measurement tables and pipeline tables alike. ## Next steps Recognized columns, units, and sign conventions. Read cycler files with the ionworksdata library. Upload cell specs, instances, and measurements via the Python API. Explore uploaded data with the interactive viewer. Archive original cycler files and link them to the measurements they produced for full provenance. Register sites, cyclers, and channels, link each measurement to the physical channel it ran on, and plan the tests you want run. # Preparing data Source: https://docs.ionworks.com/data/preparing-data Read cycler files, BDF, and EIS data into the Ionworks format with the ionworksdata library Before uploading, raw data from your cycler needs to be converted into the [Ionworks data format](/data/format). The [`ionworksdata`](https://data.docs.ionworks.com/) library reads files from common battery cyclers, auto-detects formats, and normalizes units, timestamps, and column names. ```bash theme={null} pip install ionworksdata ``` ## Supported cyclers `ionworksdata` auto-detects the file format when possible and produces a polars DataFrame with the standard columns. | Cycler | File types | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Arbin | `.csv`, `.xlsx`, `.res` | | BaSyTec | `.csv`, `.txt` (result export) | | BioLogic | `.mpt`, `.mpr`, `.txt` | | Digatron | `.csv` | | Maccor | `.txt`, `.csv`, `.xls`, `.xlsx` | | Neware | `.csv`, `.xls`, `.xlsx` (multi-sheet supported; automatic Latin-1 fallback for CSVs) | | Novonix | `.csv` | | Repower | `.csv` | | Generic CSV | `.csv` (any CSV with recognized column headers or custom mappings) | | Generic Parquet | `.parquet` (any parquet file with recognized column headers or custom mappings) | | BDF ([Battery Data Format](https://battery-data-alliance.github.io/battery-data-format/)) | `.bdf`, `.bdf.gz`, `.bdf.parquet`, `.csv` | ```python theme={null} import ionworksdata as iwd df = iwd.read.time_series("my_test.mpt", "biologic") ``` ## One-call read pipeline The format-specific readers (`read.neware`, `read.biologic`, …) return a *raw* canonical-subset frame — no step count, capacity, energy, or step summary. Prefer the high-level entrypoints, which run the full standard pipeline in a single call and accept the same `reader`, `extra_column_mappings`, and `options` arguments: * `read.time_series` — parses the file, derives step and cycle counts, and computes capacity and energy. Returns the processed time-series DataFrame. * `read.time_series_and_steps` — does everything `time_series` does, then adds the per-step summary DataFrame and runs a **validation-gated current-sign auto-fix**. ```python theme={null} import ionworksdata as iwd ts, steps = iwd.read.time_series_and_steps( "neware_data.csv", reader="neware", ) ``` With the default `options={"validate": True}`, `time_series_and_steps` validates the frame against the same checks the Ionworks API applies on upload. If validation reports a reversed or indeterminate current-sign convention, it flips the sign, recomputes capacity and energy, and re-validates — so the returned data already matches Ionworks' **positive = discharge** convention. Every read path normalizes current sign to Ionworks' **positive = discharge** convention — `set_positive_current_for_discharge` runs inside the standard processing applied by `read.`, `read.time_series`, and `read.time_series_and_steps` alike. What `time_series_and_steps` adds on top is a *validation-gated* re-check: if the upload validator still reports a reversed or indeterminate convention, it flips the sign, recomputes capacity and energy, and re-validates. The transform is detect-then-fix — it only flips when it detects that positive current corresponds to charge — so re-applying `iwd.transform.set_positive_current_for_discharge` to already-normalized data leaves the convention unchanged rather than double-flipping it (see [current sign convention troubleshooting](#incorrect-current-sign-convention)). If the source file has no step column, `time_series` derives one from current-sign transitions automatically — no manual step-index pass needed. ## Reading file metadata Cycler exports usually carry a header block above the data — the schedule that was run, when the file was exported, where it came from. `read.metadata` parses it, auto-detecting the reader the same way the read entrypoints do: ```python theme={null} import ionworksdata as iwd meta = iwd.read.metadata("maccor_data.057") meta["protocol"]["name"] # "Sodium 1 Ah Pouch 15-38.000" meta["test_setup"]["cycler"] # "Maccor" meta["test_setup"]["export_date"] # "04/30/2024" ``` The most useful field is `protocol["name"]`. It identifies the test schedule that produced the file — enough to link a measurement to its protocol on upload. Each reader extracts whatever its own format's header carries, translating its native labels into the same vocabulary — so the result keeps one shape whichever cycler wrote the file. `protocol["name"]` comes from Maccor's `Procedure`, Novonix's `Protocol`, Basytec's `Testplan`, Arbin's `Schedule File Name` or Neware's `Step file`; `test_setup["operator"]` from Arbin's `Creator`, Biologic's `User` or the Basytec equivalent. So a script reading `meta["protocol"]["name"]` does not branch on format. An export that carries no header block at all — some plain CSV variants — still yields the reader name, as below. Which other fields appear depends on what the format writes. `test_setup` can carry `cycler`, `channel_number`, `instrument`, `cycler_serial_number`, `software_version`, `operator`, `test_name`, `cell_label`, `export_date`, `end_time`, `source_file_path`, `source_file_id`, `barcode_comment`, `temperature_setpoint_degc` and `notes`, with `start_time` at the top level. `cell_label` is the cell as the *cycler* labelled it — a lab's own name or battery-type string, which need not match the cell instance you upload to. `source_file_id` belongs to the cycler's own id space and is not a cross-system identifier. `measurement_details` merges the same keys into the measurement dict you pass it, so building a measurement for upload needs no separate parsing step: ```python theme={null} details = iwd.read.measurement_details("maccor_data.057", {"name": "cell A"}) details["measurement"]["protocol"]["name"] # filled in from the header ``` Merging never overwrites a value you set yourself — pass your own `protocol={"name": ...}` in the measurement dict and it is kept, while the reader still fills in the fields you left out. Only the fields a given cycler actually writes are present, so check for a key before reading it. A reader whose format carries no header block still reports `test_setup["cycler"]` and, where it can determine one, `start_time` — so a non-empty result is not by itself evidence that the file had a header. `read.metadata` raises `ValueError` if the reader cannot be detected, the same as the read entrypoints; pass `reader=` to skip detection. A header field that maps to no known key is dropped rather than passed through under its native name, so the set of keys stays the same across formats. ## Custom column mappings If your CSV uses non-standard column names, map them to the standard names with `extra_column_mappings`. The mapping overrides the reader's built-in rename table for any raw column you name, so auto-detection is skipped for it. The *standard* name you map to still drives the reader's normal unit handling — mapping a column to `Current [mA]` declares its values are milliamps and the reader converts them to `Current [A]`. Pick the target name that matches the column's real unit, not the one its header claims (see [overriding misleading current-unit headers](#overriding-misleading-current-unit-headers)). ```python theme={null} import ionworksdata as iwd df = iwd.read.time_series( "my_data.csv", "csv", extra_column_mappings={ "vCell": "Voltage [V]", "iCell": "Current [A]", "t": "Time [s]", "Tcell": "Temperature [degC]", }, ) ``` Partial mappings work too — any standard column you don't map is still auto-detected: ```python theme={null} df = iwd.read.time_series( "my_data.csv", "csv", extra_column_mappings={"vCell": "Voltage [V]"}, ) ``` Use `extra_column_mappings` when your CSV comes from a custom test setup or proprietary cycler. Mapped values are used as-is for standard-unit targets (V, A, s, °C) — no rescaling is applied — so ensure your data is already in those units before using this parameter. The one exception is a milliamp target: mapping to `Current [mA]` triggers the reader's milliamp-to-amp conversion (see [overriding misleading current-unit headers](#overriding-misleading-current-unit-headers)). ## Generic parquet files For parquet files that don't follow the [BDF spec](#battery-data-format-bdf), use the generic parquet reader. It mirrors the CSV reader's column-detection strategy — recognizing common aliases for voltage, current, time, and temperature — but skips text-parsing concerns since parquet is strongly typed and has unambiguous column names. No separator, encoding, or quote handling is needed. Use it when you have cycler data already exported to parquet (for example, from an internal pipeline or another tool) and want it normalized into the [Ionworks data format](/data/format). Any `.parquet` file (except `.bdf.parquet`) is auto-detected; you can also select the reader explicitly: ```python theme={null} import ionworksdata as iwd # Auto-detected by extension df = iwd.read.time_series("cell.parquet") # Or call the reader directly df = iwd.read.parquet("cell.parquet") ``` If the file uses non-standard column names, pass `extra_column_mappings` just like with the CSV reader: ```python theme={null} df = iwd.read.parquet( "cell.parquet", extra_column_mappings={ "vCell": "Voltage [V]", "iCell": "Current [A]", "t": "Time [s]", }, ) ``` BDF parquet files (`.bdf.parquet`) are still routed to the BDF reader — the generic parquet reader is only used as a fallback for `.parquet` files that aren't BDF. ## Battery Data Format (BDF) `ionworksdata` can read and write files in the [Battery Data Format (BDF)](https://battery-data-alliance.github.io/battery-data-format/) defined by the Battery Data Alliance. CSV, gzipped CSV, and parquet variants are all supported. Files are auto-detected by their header or extension (`.bdf`, `.bdf.gz`, `.bdf.parquet`). ```python theme={null} import ionworksdata as iwd # Read df = iwd.read.bdf("cell.bdf") df = iwd.read.time_series("cell.bdf.parquet") # via the generic entrypoint # Write iwd.write.bdf(df, "out.bdf") # CSV with preferred labels iwd.write.bdf(df, "out.bdf.gz") # gzipped CSV iwd.write.bdf(df, "out.bdf.parquet") # parquet iwd.write.bdf(df, "out.bdf", use_machine_readable_names=True) ``` BDF does not mandate a current sign convention. The reader normalises current to the Ionworks convention (**positive = discharge**) on load, so third-party BDF files that follow the opposite IEC convention are flipped automatically. The writer emits whatever convention is in the input DataFrame — pass the data through `transform.set_positive_current_for_discharge` first if you need to guarantee discharge-positive output. ## EIS and impedance data | Instrument | File types | | ---------- | ----------------------------------------------------------- | | Arbin | `.xlsx` (impedance sweeps on the `ACIM` sheet) | | BioLogic | `.mpt`, `.mpr`, `.txt` (files containing impedance columns) | | Gamry | `.dta` (ZCURVE table) | Impedance data is read into columns `Frequency [Hz]`, `Z_Re [Ohm]`, `Z_Im [Ohm]`, `Z_Mod [Ohm]`, and `Z_Phase [deg]`. ```python theme={null} import ionworksdata as iwd df = iwd.read.gamry("eis_measurement.dta") ``` See the [data format](/data/format#eis-and-impedance-data) page for the full column spec and sign convention. ## Reader gotchas A few cycler exports need a small nudge to read cleanly. ### Multi-sheet Neware Excel (BTSDA) A Neware BTSDA `.xlsx` export splits into `unit`, `test`, `cycle`, `step`, and `record` sheets, with the time series on the `record` sheet. The reader picks `record` automatically when it is present, so no `options` are needed: ```python theme={null} import ionworksdata as iwd ts, steps = iwd.read.time_series_and_steps("neware_btsda.xlsx", reader="neware") ``` The `record` sheet often has no step or cycle column — step count is derived from current-sign transitions automatically. For workbooks whose time series sheet is named something else (older `.xls` exports use `Detail_1`, `Detail_1_1`, …), name the sheets explicitly. The reader concatenates multiple sheets and sorts by timestamp: ```python theme={null} ts, steps = iwd.read.time_series_and_steps( "neware_detail.xls", reader="neware", options={"sheets": {"type": "pattern", "value": "Detail_"}}, ) ``` `sheets` also accepts `{"type": "name", "value": "record"}` for a single named sheet (or a list of names), and `{"type": "all"}` to read every sheet. ### Overriding misleading current-unit headers The Neware reader maps amp-headed columns (`Current (A)`, `Current(A)`) to `Current [A]` and milliamp-headed columns (`Current (mA)`, `Cur(mA)`) to `Current [mA]`. Trust the *values*, not the header text: after reading, sanity-check the current range against the cell's expected C-rate. If a header lies about its unit (for example, an amp-headed column that actually carries milliamps), override the target mapping with `extra_column_mappings`: ```python theme={null} df = iwd.read.time_series( "mislabeled.csv", "neware", extra_column_mappings={"Current (A)": "Current [mA]"}, ) ``` `extra_column_mappings` is merged on top of the reader's built-in rename table, overriding the built-in entry for any raw column you map. Mapping to `Current [mA]` is a deliberate *unit declaration*, not a raw passthrough — the reader still applies its normal milliamp-to-amp conversion (dividing by 1000 to produce `Current [A]`), which is exactly what corrects the mislabeled column. ### CSV files with a metadata preamble or ragged rows Some cyclers write a block of metadata above the real header — each line a label padded out to the table width — or end every data row with a trailing delimiter, so the rows carry one more field than the header has names. Both are handled automatically: the reader locates the header rather than assuming the first line, and drops the surplus fields before matching columns. ```python theme={null} df = iwd.read.time_series("export_with_preamble.csv") ``` No `options` are needed. If a file still fails to parse, the header is likely unrecognised rather than misplaced — see [custom column mappings](#custom-column-mappings). ### Arbin impedance sweeps on the ACIM sheet An Arbin workbook writes impedance sweeps to their own `ACIM` sheet, separate from the cycling data. `read.time_series()` splices those sweeps into the cycling data in test-time order, as their own step: ```python theme={null} df = iwd.read.time_series("test_with_eis.xlsx", "arbin") ``` A sweep is measured against frequency rather than against the clock, so it has no timestamp of its own. Its rows carry null `Time [s]`, `Voltage [V]`, and `Current [A]`, alongside the populated impedance columns — recording that the cycler took no time-domain sample, rather than inventing one. Filter on a non-null `Frequency [Hz]` to separate the sweeps back out. To read the cycling data alone, opt out: ```python theme={null} df = iwd.read.time_series("test_with_eis.xlsx", "arbin", options={"include_eis": False}) ``` ### Maccor exports in milliamps Maccor exports that head their columns `Current (mA)`, `Capacity (mAHr)`, or `Energy (mWHr)` are read and converted to `Current [A]`, `Capacity [A.h]`, and `Energy [W.h]`. Do not map these with `extra_column_mappings` — that renames without converting, giving values 1000x too large. ### Novonix rows with no sensor attached Novonix writes `-9999` in a temperature column when no sensor is connected. Those are read as missing values rather than as a real reading, so a channel with no thermocouple leaves `Temperature [degC]` null instead of dragging an average down by four orders of magnitude. ### Vendor and MES re-exports When a platform like Voltaiq re-exports a cycler file (extra derived columns plus a timezone-aware `Timestamp`), the native cycler reader can choke on the timezone with a datetime parse error. Fall back to the generic CSV reader and map the already-elapsed time column so no timestamp parsing is needed: ```python theme={null} df = iwd.read.time_series( "voltaiq_export.csv", "csv", extra_column_mappings={ "Test Time (s)": "Time [s]", "Voltage (V)": "Voltage [V]", "Current (A)": "Current [A]", }, ) ``` ## Troubleshooting ### Incorrect current sign convention **Problem:** When uploading measurement data, you receive an error like: > Current sign convention error: positive current appears to be charge, not > discharge. **Solution:** Ionworks expects **positive current = discharge** and **negative current = charge**. If your cycler uses the opposite convention, convert the data before uploading: ```python theme={null} import ionworksdata as iwd data = iwd.transform.set_positive_current_for_discharge(data) ``` ### Ambiguous current sign convention **Problem:** When uploading measurement data, you receive an error like: > Current sign convention error: the sign convention is ambiguous. This happens when all current values have the same sign, so the validator cannot determine whether positive means charge or discharge. **Solution:** Use the same transform — it uses voltage-response analysis (fitting an OCV-R equivalent circuit model under both sign conventions) to infer charge vs. discharge direction even when all currents share the same sign: ```python theme={null} import ionworksdata as iwd data = iwd.transform.set_positive_current_for_discharge(data) ``` If the automatic approach does not work for your data (for example, flat voltage profiles), you can manually apply a sign based on the step type column from your cycler: ```python theme={null} import polars as pl # Replace "Step type" and "charge" with your cycler's column name # and step-type label (e.g. "CC_Charge", "Charge", "C", etc.) data = data.with_columns( pl.when(pl.col("Step type") == "charge") .then(-pl.col("Current [A]").abs()) .otherwise(pl.col("Current [A]").abs()) .alias("Current [A]") ) ``` ### Unsigned (magnitude-only) current **Problem:** When uploading measurement data, you receive an error like: > Current sign convention error: the current appears to be unsigned > (magnitude-only) but contains both charge and discharge steps. Sign it using > the cycler mode column via > `ionworksdata.transform.set_positive_current_for_discharge(data)` before > validation. This fires when every non-rest current sample is positive yet the data clearly contains both charge and discharge steps — a fingerprint of a cycler that records current as a magnitude and encodes direction in a separate mode column rather than in the sign of `Current [A]`. The issue is distinct from [ambiguous sign convention](#ambiguous-current-sign-convention) because it has a concrete, deterministic fix: re-sign the current from the cycler's own charge/discharge labels. **Solution:** Run `set_positive_current_for_discharge`. When it detects an all-positive non-rest current alongside both charge and discharge steps, it flips the sign of charge-step samples using the cycler mode column instead of falling back to the voltage-response heuristic: ```python theme={null} import ionworksdata as iwd data = iwd.transform.set_positive_current_for_discharge(data) ``` The issue code is `CURRENT_SIGN_UNSIGNED` — branch on it explicitly if you want to apply the auto-fix without prompting: ```python theme={null} from ionworks import IssueCode, MeasurementValidationError import ionworksdata as iwd try: client.cell_measurement.create(cell_instance.id, data) except MeasurementValidationError as e: if e.has_code(IssueCode.CURRENT_SIGN_UNSIGNED): data = iwd.transform.set_positive_current_for_discharge(data) client.cell_measurement.create(cell_instance.id, data) else: raise ``` ### Swapped charge and discharge cumulative columns **Problem:** When uploading with `validate_strict=True`, you receive an error like: > Column 'Discharge capacity \[A.h]' disagrees with the running integral of > 'max(I, 0)' over time by up to 96.4% (exceeds 10% tolerance). …and the reported `Discharge capacity [A.h]` looks like it tracks the charge half-wave, with the same flipped behaviour on `Charge capacity [A.h]`. This happens with some half-cell exports and cycler configurations that label the two cumulative columns inversely. **Solution:** Use `fix_swapped_charge_discharge_columns` to compare each column against the trapezoidal integral of current (and power, for energy) and rename the pair when the swapped assignment is within tolerance and the as-is assignment is not. ```python theme={null} import ionworksdata as iwd # Required prerequisite — without a known sign convention, "Discharge" vs # "Charge" has no ground-truth meaning to compare against. data = iwd.transform.set_positive_current_for_discharge(data) # Inspects "Discharge/Charge capacity [A.h]" and, when present, # "Discharge/Charge energy [W.h]". Returns `data` unchanged when no swap # is warranted. data = iwd.transform.fix_swapped_charge_discharge_columns(data) ``` The transform requires `Time [s]`, `Step count`, and `Current [A]` columns, and uses `Power [W]` (or `Voltage [V] * Current [A]` when `Power [W]` is absent) for the energy pair. The default `tolerance` is 10 % relative error; pass a different value if your data needs a tighter or looser threshold: ```python theme={null} data = iwd.transform.fix_swapped_charge_discharge_columns(data, tolerance=0.05) ``` The function refuses to swap labels when positive current does not correspond to discharge — without a known sign convention there is no way to tell which column is which, and swapping would mask a real sign- convention bug. Run `set_positive_current_for_discharge` first. ### Time not cumulative **Problem:** Time resets to 0 for each cycle. **Solution:** Track a cumulative time offset: ```python theme={null} cumulative_time_offset = 0.0 for cycle_num, cycle_df in df.group_by("Cycle from cycler"): cycle_df = cycle_df.with_columns( (pl.col("Time [s]") + cumulative_time_offset).alias("Time [s]") ) cumulative_time_offset = cycle_df["Time [s]"].max() ``` ### Step count not cumulative **Problem:** Step count resets for each cycle. **Solution:** Track a step count offset across cycles: ```python theme={null} step_offset = 0 for cycle_num, cycle_df in df.group_by("Cycle count"): cycle_df = cycle_df.with_columns( (pl.col("Step from cycler") + step_offset).alias("Step count") ) step_offset = cycle_df["Step count"].max() + 1 ``` ### Missing capacity columns **Problem:** Capacity calculation fails. **Solution:** Ensure you have `Time [s]`, `Current [A]`, and `Voltage [V]` columns before calculating capacity. ### Non-UTF-8 CSV files (e.g. Neware) **Problem:** Reading a Neware CSV file fails with an encoding error. **Solution:** The Neware reader automatically falls back to Latin-1 if UTF-8 decoding fails, so no action is needed in most cases: ```python theme={null} import ionworksdata as iwd df = iwd.read.time_series("neware_data.csv", "neware") ``` To explicitly control the encoding: ```python theme={null} df = iwd.read.time_series( "neware_data.csv", "neware", options={"file_encoding": "latin-1"}, ) ``` ## Next steps Full reference for recognized columns, units, and sign conventions. Upload prepared data as cell specs, instances, and measurements. Complete reference for `read`, `write`, `transform`, `steps`, and `load`. Report issues or browse the source. # Raw data Source: https://docs.ionworks.com/data/raw-data Store original cycler files as-is, then link them to the measurements they produced for full provenance Raw data records let you archive the **original files that came off your cycler** — untouched — inside Ionworks Studio, then link them to the processed [cell measurements](/data/measurements) they produced. This gives every measurement a clear provenance trail back to its source. Raw data records live under a [project](/core-concepts/projects-studies), not under a specific cell instance. One raw file can feed many measurements, and one measurement can be assembled from many raw files. ## When to use raw data Use raw data records when you want to: * Keep the untransformed cycler export (e.g. `.mpr`, `.res`, `.nda`, `.txt`, or a Neware / Maccor / Biologic file) alongside the processed [time series](/data/measurements#time-series) it was converted into. * Trace a measurement back to the exact file — and file version — it came from. * Store one source file that was split into several measurements (for example, a single test file containing formation + RPT + cycling that you uploaded as three separate measurements). * Combine several source files into one measurement (for example, a run that was paused and resumed, producing two export files). If you only care about the processed data, you do not need to create raw data records — regular measurement upload is enough. Raw data is additive provenance, not a replacement for [preparing data](/data/preparing-data) and [uploading data](/data/uploading). Raw data records store the file **as-is**. They are not processed into measurements automatically — you still convert your file with [`ionworksdata`](/data/preparing-data) and upload the resulting measurement separately, then attach the raw file for provenance. ## Upload a raw file Upload any file from a local path or an open binary handle. The client derives the filename from the file path or handle: ```python theme={null} raw = client.raw_data.upload( project_id=project.id, file="exports/cellA_formation.mpr", name="Cell A — formation export", metadata={"cycler": "Biologic VMP3", "operator": "Jane Smith"}, ) print(raw.id, raw.filename, raw.size_bytes) ``` **Arguments:** | Argument | Description | | ------------ | ------------------------------------------------------------------------ | | `project_id` | Project the raw file belongs to. Required. | | `file` | Path (`str` or `os.PathLike`) or an open binary file-like object. | | `name` | Human-readable label. Defaults to the file's basename. | | `metadata` | Free-form dict — cycler make/model, operator, channel, source path, etc. | The returned `RawData` object has `id`, `project_id`, `name`, `filename`, `content_type`, `size_bytes`, `metadata`, and timestamps. ## List, get, and download ```python theme={null} # List a project's raw files, newest first files = client.raw_data.list(project_id=project.id, limit=50) for r in files.items: print(r.id, r.name, r.filename, r.size_bytes) # Fetch one record raw = client.raw_data.get(raw_data_id) # Get a short-lived signed download URL url = client.raw_data.download_url(raw_data_id) ``` `list` returns a paginated response with `items`, `count`, and `total`. The `download_url` is signed and expires after a few minutes — request a fresh one each time you need to download the file. ## Update and delete `name` and `metadata` are patchable. The underlying file is immutable — to replace the bytes, delete the record and upload again. ```python theme={null} client.raw_data.update( raw_data_id, name="Cell A — formation export (re-labeled)", metadata={"cycler": "Biologic VMP3", "channel": 4}, ) client.raw_data.delete(raw_data_id) ``` Deleting a raw data record removes the stored file and clears any links to measurements. The linked measurements themselves are **not** deleted. ## Link raw files to measurements The provenance link is many-to-many and lives on the measurement side of the SDK. Attaching is bulk and idempotent — re-attaching an already-linked file is a no-op. ```python theme={null} # Attach one or more raw files to a measurement client.cell_measurement.attach_raw_data( cell_measurement_id=measurement.id, raw_data_ids=[raw.id], ) # List the raw files behind a measurement sources = client.cell_measurement.list_raw_data(measurement.id) for r in sources.items: print(r.filename) # Detach one link (does not delete the raw file) client.cell_measurement.detach_raw_data( cell_measurement_id=measurement.id, raw_data_id=raw.id, ) ``` To go the other direction — find every measurement that was produced from a given raw file — use `list_measurements` on the raw data client: ```python theme={null} measurements = client.raw_data.list_measurements(raw_data_id) for measurement_id in measurements.items: print(measurement_id) ``` Deleting a measurement removes only its provenance links, never the raw data record or its file. Raw files are independent, project-owned assets. ## End-to-end example Upload the processed measurement, then archive the original file and link them: ```python theme={null} import ionworksdata as iwd source_path = "exports/cellA_formation.mpr" # 1. Process the cycler file into the Ionworks format data = iwd.read(source_path) # 2. Upload the processed measurement bundle = client.cell_measurement.create( cell_instance.id, { "measurement": {"name": "Cell A — formation"}, "time_series": data, }, validate_strict=True, ) # 3. Archive the original file raw = client.raw_data.upload( project_id=project.id, file=source_path, name="Cell A — formation export", metadata={"cycler": "Biologic VMP3"}, ) # 4. Link the raw file to the measurement it produced client.cell_measurement.attach_raw_data( cell_measurement_id=bundle.id, raw_data_ids=[raw.id], ) ``` ## Next steps Convert cycler files into the Ionworks format before uploading a measurement. Create cell specs, instances, and measurements via the Python API. Time series, properties, and file measurement types. Group raw data, studies, and simulations by research initiative. # Reading data Source: https://docs.ionworks.com/data/reading List, filter, paginate, and retrieve cell specs, instances, and measurements with the Python API Once data is uploaded, you can read it back using the `ionworks-api` Python client. This page covers listing and filtering resources, retrieving full measurement detail, local caching, in-Python plotting, and error handling. For installation and authentication, see the [Python API client](/api-client) page. For uploading, see [uploading data](/data/uploading). You can find the ID for any cell specification, instance, or measurement from the data visualization pages in Ionworks Studio. The ID is displayed in the URL and in the detail panels. ## Listing resources ```python theme={null} # List cell specifications (first page) specs = client.cell_spec.list() for spec in specs[:5]: print(f" - {spec.name} (form_factor: {spec.form_factor})") # Get a specific cell spec with full nested data full_spec = client.cell_spec.get(spec_id) print(f"Capacity: {full_spec.ratings['capacity']['value']} " f"{full_spec.ratings['capacity']['unit']}") # List instances for a spec and pick the first instances = client.cell_instance.list(spec_id) instance = instances[0] # List measurements for an instance measurements = client.cell_measurement.list(instance.id) ``` ## Filtering and ordering All `list()` methods accept keyword-only filter parameters so you can narrow results server-side instead of fetching everything and filtering in Python. ```python theme={null} # Search cell specs by name (case-insensitive substring match) specs = client.cell_spec.list(name="graphite") # Exact name match. Spec names are unique per project, so this resolves within # the project configured on the client (or an explicit project_id=...) and # raises if neither is set — an org-wide exact-name match would be ambiguous. specs = client.cell_spec.list(name_exact="NCM622/Graphite Coin Cell") # Filter by form factor (cell specs only) specs = client.cell_spec.list(form_factor="R2032") # Filter by creator specs = client.cell_spec.list(created_by_email="jane") # Date range filters specs = client.cell_spec.list( created_after="2026-01-01T00:00:00Z", created_before="2026-04-01T00:00:00Z", ) # Sort results specs = client.cell_spec.list(order_by="created_at", order="desc") ``` Filters work the same way across all three resource types and can be combined with pagination and ordering in a single call: ```python theme={null} instances = client.cell_instance.list( spec_id, limit=50, offset=0, name="batch-A", order_by="updated_at", order="desc", ) measurements = client.cell_measurement.list( instance_id, measurement_type="time_series", created_after="2026-03-01T00:00:00Z", order_by="created_at", order="asc", ) ``` Cell measurements support additional date filters for the measurement start time: ```python theme={null} measurements = client.cell_measurement.list( instance_id, started_after="2026-03-01T00:00:00Z", started_before="2026-03-31T23:59:59Z", ) ``` ### Filter parameters | Parameter | Type | Description | Available on | | ------------------ | ----- | ------------------------------------------------------------------------------------------------- | ----------------------- | | `name` | `str` | Case-insensitive substring match on name. | All | | `name_exact` | `str` | Exact match on name. Takes precedence over `name`. | All | | `form_factor` | `str` | Exact match on form factor. | `cell_spec` only | | `measurement_type` | `str` | Filter by measurement type (`"time_series"`, `"properties"`, `"file"`). | `cell_measurement` only | | `created_by_email` | `str` | Case-insensitive substring match on creator email. | All | | `created_after` | `str` | ISO datetime; records created after this time. | All | | `created_before` | `str` | ISO datetime; records created before this time. | All | | `updated_after` | `str` | ISO datetime; records updated after this time. | All | | `updated_before` | `str` | ISO datetime; records updated before this time. | All | | `started_after` | `str` | ISO datetime; measurements started after this time. | `cell_measurement` only | | `started_before` | `str` | ISO datetime; measurements started before this time. | `cell_measurement` only | | `order_by` | `str` | Column to sort by (`"name"`, `"created_at"`, `"updated_at"`, or `"start_time"` for measurements). | All | | `order` | `str` | Sort direction: `"asc"` or `"desc"`. | All | Filter parameters can be combined freely with each other and with the `limit`/`offset` pagination parameters. The `.total` property on the returned `PaginatedList` reflects the total count *after* filters are applied. ## Pagination All `list()` calls return a `PaginatedList`. The `limit` and `offset` parameters control which page is fetched. ```python theme={null} page = client.cell_spec.list(limit=50, offset=0) print(f"Showing {page.count} of {page.total} specs") next_page = client.cell_spec.list(limit=50, offset=50) ``` | Parameter | Type | Default | Description | | --------- | ----- | --------------------- | ------------------------------------------------- | | `limit` | `int` | Server default (1000) | Maximum number of items to return (1 to 1000). | | `offset` | `int` | `0` | Number of items to skip before returning results. | The returned `PaginatedList` behaves like a regular Python list (iterate, index, check length) and also exposes: | Property | Description | | -------- | -------------------------------------------------- | | `.items` | The list of results for the current page. | | `.total` | Total number of matching records across all pages. | | `.count` | Number of items in the current page. | To iterate through all results: ```python theme={null} all_specs = [] offset = 0 limit = 100 while True: page = client.cell_spec.list(limit=limit, offset=offset) all_specs.extend(page.items) if len(all_specs) >= page.total: break offset += limit ``` ## Resolving a measurement by name When you know the human-readable names of a measurement and its parents but not their ids, use `client.resolve_measurement()` instead of hand-walking the spec → instance → measurement hierarchy: ```python theme={null} measurement = client.resolve_measurement( cell_specification="NCM622/Graphite Coin Cell", cell_instance="Cell A #1", measurement="RPT 0", ) # Use the resolved id with the rest of the API detail = client.cell_measurement.detail(measurement.id) ``` The specification is resolved within the [project configured on the client](/api-client#default-project); pass `project_id=...` to target a different one. Spec names are unique per project rather than per organization, so an unscoped lookup would treat a name shared with a sibling project as ambiguous. The method filters each level server-side by exact name and returns the matching `CellMeasurement`. It raises `IonworksError` with: * `status_code=404` if any level has no match. * `status_code=409` if a name is ambiguous within its parent — in that case, resolve by id instead (for example, via the data visualization pages in Ionworks Studio). ```python theme={null} from ionworks import IonworksError try: m = client.resolve_measurement("NCM622 Spec", "Cell A #1", "RPT 0") except IonworksError as exc: if exc.status_code == 404: print("Not found — check the names") elif exc.status_code == 409: print("Ambiguous name — resolve by id instead") else: raise ``` ## Measurement detail `client.cell_measurement.detail()` retrieves the full measurement and adapts its response based on the measurement type. ```python theme={null} measurement_detail = client.cell_measurement.detail(measurement_id) ``` Returns time series data, step statistics, and cycle metrics: | Field | Description | | ------------------ | ------------------------------------------------------------------------------------- | | `measurement` | Measurement metadata (name, protocol, test setup, notes) | | `time_series` | Full time series data as a DataFrame (polars by default) | | `steps` | Step-level statistics as a DataFrame | | `cycles` | Cycle-level metrics (capacity, efficiency, etc.) as a DataFrame | | `specification_id` | ID of the parent cell specification (use `client.cell_spec.get(id)` to fetch) | | `instance_id` | ID of the parent cell instance (use `client.cell_instance.get(spec_id, id)` to fetch) | ```python theme={null} detail = client.cell_measurement.detail(measurement_id) print(f"Time series shape: {detail.time_series.shape}") print(detail.cycles.head()) ``` Returns the measurement metadata with properties included. No file data is fetched. ```python theme={null} detail = client.cell_measurement.detail(measurement_id) props = detail.measurement.properties print(f"Thickness: {props['thickness']['value']} {props['thickness']['unit']}") ``` Downloads all attached files and returns them as a `files` dict mapping filename to bytes: ```python theme={null} detail = client.cell_measurement.detail(measurement_id) for filename, content in detail.files.items(): with open(filename, "wb") as f: f.write(content) ``` ## Linking to the web app Use `client.urls.measurement()` to build a link to a measurement's detail page in the Ionworks web app. This is useful when you want to surface a clickable link from a notebook, script, or report so collaborators can jump straight to the measurement in Ionworks Studio. ```python theme={null} url = client.urls.measurement(measurement_id, project_id) # https://app.ionworks.com/dashboard/projects//data/measurements/ ``` | Parameter | Type | Description | | ---------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `measurement_id` | `str` | ID of the measurement to link to. | | `project_id` | `str \| None` | ID of the project the measurement belongs to. Defaults to the [project configured on the client](/api-client#default-project). | A common pattern is to render a link next to each result while iterating through measurements: ```python theme={null} for m in client.cell_measurement.list(instance_id): print(f"{m.name}: {client.urls.measurement(m.id, project_id)}") ``` `client.urls` exposes the same helper for every routed resource — studies, simulations, parameterized models, pipelines, optimizations, protocols, materials, cell specs, and cell instances. See [Web app URL helpers](/api-client#web-app-url-helpers) for the full reference. ## Navigator: cached hierarchy walks `Navigator` is an opt-in helper that walks the spec → instance → measurement hierarchy and memoises every list and fetch call in memory. Use it when you want to iterate over many specs, instances, or measurements in a single script or notebook and avoid repeating the same API calls. Reach for `Navigator` when: * You're writing an analysis script that loops over every measurement on one or more cell specs. * You want deterministic iteration order — listings are returned sorted by `name`. * You want pagination handled automatically without managing `limit` and `offset` yourself. The underlying sub-clients (`client.cell_spec`, `client.cell_instance`, `client.cell_measurement`) remain the primary API. `Navigator` is a thin layer on top — use it when you want a single cached view of the hierarchy, and use the sub-clients directly for one-off reads or writes. ```python theme={null} from ionworks import Ionworks, Navigator nav = Navigator(Ionworks()) # Walk every measurement on every instance of every spec for spec_name in nav.specs(): for inst in nav.instances(spec_name): for m in nav.measurements(inst.id): ts = nav.time_series(m.id) steps = nav.steps(m.id) # ... your analysis ... ``` Each entity is fetched at most once per `Navigator` instance. Calling `nav.instances("CellA")` twice returns the same list without a second API round-trip; the same applies to `measurements`, `steps`, and `time_series`. ### Configuration ```python theme={null} nav = Navigator(client=Ionworks(), page_size=200) ``` | Parameter | Description | | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `client` | An existing `Ionworks` client. If omitted, a default client is constructed (which reads `IONWORKS_API_KEY` from the environment). | | `page_size` | Items per page when paginating `cell_instance.list` and `cell_measurement.list`. Defaults to `200`. | | `project_id` | Project whose specs the navigator walks. Defaults to the [project configured on the client](/api-client#default-project). | `Navigator` looks specs up by name, and spec names are unique within a project rather than across the organization — so the name-keyed methods (`specs`, `spec`, `instances`) always walk exactly one project and raise `ValueError` if neither `project_id` nor a client default is set. Navigating by id (`measurements`, `steps`, `time_series`) needs no project. ### Looking up a single spec ```python theme={null} spec = nav.spec("CellA") ``` Raises `KeyError` with the list of available spec names if the name doesn't match — useful for catching typos. ### Invalidating the cache Battery data is immutable once uploaded, so the only staleness mode is "a new sibling appeared on the platform." For long-running notebooks where new data may have been uploaded mid-session, you can drop part or all of the cache: ```python theme={null} nav.clear() # drop everything nav.invalidate(spec_name="CellA") # drop CellA + its instances + measurements nav.invalidate(instance_id="inst_123") # drop one instance + its measurements nav.invalidate(measurement_id="meas_456") # drop one measurement's steps + time series ``` Invalidation cascades downward: dropping a spec also drops its instances and their measurements; dropping an instance also drops its measurements. `Navigator` caches in memory for the lifetime of the instance. For cross-process or cross-session caching of measurement data on disk, see [Local caching](#local-caching) below — the two layers compose. ## Local caching The `ionworks-api` client automatically caches measurement data to disk so repeated reads are fast and avoid unnecessary API calls. Caching is enabled by default and applies to the `steps`, `cycles`, `steps_and_cycles`, and `time_series` methods on `cell_measurement`. When you call a method like `client.cell_measurement.steps(measurement_id)`, the client checks a local cache directory before making an API request. If a cached copy exists and hasn't expired, it's returned directly. Otherwise, the client fetches from the API, caches the result, and returns it. Cached data is stored as Parquet files in `~/.ionworksdata_cache` by default and expires after one hour. ### Skipping the cache Every data-fetching method accepts a `use_cache` parameter. Set it to `False` to force a fresh API call without reading from or writing to the local cache: ```python theme={null} steps = client.cell_measurement.steps(measurement_id, use_cache=False) time_series = client.cell_measurement.time_series(measurement_id, use_cache=False) ``` ### Configuring the cache ```python theme={null} import ionworks ionworks.set_cache_directory("/path/to/custom/cache") ionworks.set_cache_ttl(7200) # 2 hours ionworks.set_cache_ttl(None) # never expire ionworks.set_cache_enabled(False) ionworks.set_cache_enabled(True) deleted_count = ionworks.clear_cache() ``` | Function | Description | | --------------------------- | --------------------------------------------------------------------------- | | `set_cache_enabled(bool)` | Enable or disable caching globally. | | `get_cache_enabled()` | Return whether caching is currently enabled. | | `set_cache_directory(path)` | Set the directory for cache files. Default: `~/.ionworksdata_cache`. | | `set_cache_ttl(seconds)` | Set the TTL in seconds. Pass `None` to disable expiration. Default: `3600`. | | `get_cache_directory()` | Return the current cache directory path. | | `get_cache_ttl()` | Return the current TTL value. | | `clear_cache()` | Delete all cached files and return the number deleted. | Cache configuration is global. Changes affect all subsequent API calls in the same Python process. ## Plotting from Python `DataLoader` includes a `plot_data()` method for quick matplotlib-based visualization of measurement data. The plot displays voltage and current over time, with an additional temperature subplot when temperature data is available. ```python theme={null} from ionworksdata import DataLoader loader = DataLoader.from_db("measurement-id-here") fig, ax = loader.plot_data() ``` The method returns a matplotlib `(Figure, Axes)` tuple so you can customize the plot further. Pass `show=True` to display the plot immediately: ```python theme={null} fig, ax = loader.plot_data(show=True) ``` `plot_data()` automatically loads time series data from the server if it hasn't been fetched yet. For the interactive in-browser viewer (with filters, step overlays, SQL), see [visualizing data](/data/visualizing). ## Inline time series size limit When you pass a pandas or polars DataFrame directly in an API call (for example, as part of a pipeline configuration), the client enforces a maximum of **1,000 rows** for inline time series data. The same limit applies to `"file:..."` and `"folder:..."` references, since the client reads them from your local machine and inlines their contents. Larger datasets should be uploaded as measurements first, then referenced by ID. ```python theme={null} from ionworks import MeasurementValidationError, IonworksError try: client.pipeline.create(config_with_large_inline_df) except MeasurementValidationError as e: # Specifically handle the size limit violation print(e) # "Time series has 5000 rows, which exceeds the maximum of 1000 rows # for inline data. Upload the data as a measurement using # client.cell_measurement.create() and reference it with # 'db:' or iwdata.DataLoader.from_db(MEASUREMENT_ID) # instead." except IonworksError as e: print(e) ``` To work with larger datasets, upload first and reference by ID: ```python theme={null} bundle = client.cell_measurement.create(instance_id, measurement_data) from ionworksdata import DataLoader loader = DataLoader.from_db(bundle.id) ``` ### Pinning a measurement to a time window A measurement that is still being recorded grows as new rows are [appended](/data/uploading#extending-an-unfinished-measurement), so re-running a pipeline against it later reads more data than the first run did. Pass a `time_range` to pin the run to a fixed window: ```python theme={null} from ionworksdata import DataLoader loader = DataLoader.from_db( measurement_id, time_range={"start": 0, "end": 3600}, ) ``` Bounds are elapsed `Time [s]` values measured from the first sample, not wall-clock datetimes. That keeps the window meaningful for a measurement with no recorded start time, and stops it drifting if that metadata is later edited. A window whose `end` precedes its `start` is rejected when the configuration is validated. The window is recorded in `to_config()` and applied when the configuration is resolved for a run. A local `from_db()` still loads the full current series. ### Exporting DataLoader configurations If you have a `DataLoader` that references a database measurement and you want to export a self-contained configuration (for example, to share with a colleague), use `to_local()` to embed the data inline: ```python theme={null} from ionworksdata import DataLoader loader = DataLoader.from_db("measurement-id-here") local_loader = loader.to_local() # Now to_config() returns the full data instead of a DB reference config = local_loader.to_config() ``` `to_local()` fetches all time series and step data from the server immediately. For very large measurements, this may take a moment. ## Error handling The client raises exceptions for common error cases: * Missing or invalid API credentials * API request errors (raises `IonworksError` with details) * Inline time series exceeding 1,000 rows (raises `MeasurementValidationError`, a subclass of `IonworksError`) ```python theme={null} from ionworks import IonworksError try: client.cell_spec.list() except IonworksError as e: print(f"API error: {e}") ``` See [inline time series size limit](#inline-time-series-size-limit) above for the `MeasurementValidationError` handling pattern. ### API error format All API errors return a consistent JSON structure: ```json theme={null} { "error_code": "CONFLICT", "message": "Cell specification with this name already exists", "detail": { "resource_type": "cell_specification", "resource_name": "My Cell Spec", "existing_id": "abc-123" } } ``` | Field | Type | Description | | ------------ | ---------------- | -------------------------------------------------------------------------------------------------------- | | `error_code` | `string` | Machine-readable error code (e.g. `NOT_FOUND`, `CONFLICT`, `BAD_REQUEST`). | | `message` | `string` | Human-readable description of what went wrong. | | `detail` | `object \| null` | Optional additional context about the error. Contents vary by error type; may be absent for some errors. | Common HTTP status codes: | Status | Error code | Description | | ------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `BAD_REQUEST` | The request is invalid or missing required fields. | | `403` | `FORBIDDEN` | You don't have permission to access this resource. Also returned for missing or invalid API credentials (the API does not use `401`). | | `404` | `NOT_FOUND` | The requested resource does not exist. | | `409` | `CONFLICT` | A resource with the same name or identifier already exists. The `detail` field includes the `existing_id` when available. | | `409` | `MEASUREMENT_PROCESSING` | The measurement uploaded fine and its step data is still being derived. Wait and retry — see [step data still processing](#step-data-still-processing) below. | | `422` | `MEASUREMENT_DATA_MISSING` | The measurement exists but its stored step or time-series data is missing or empty. Returned by step reads, step filtering, and signed-download-URL requests. See [missing measurement data](#missing-measurement-data) below. | | `429` | `USAGE_LIMIT_REACHED` | Your organization has exceeded its usage quota for this billing cycle. | ### Step data still processing Step summaries are derived asynchronously after an upload lands, so a read issued immediately afterwards can arrive before `steps.parquet` exists. That is not an error state: step reads return `409 MEASUREMENT_PROCESSING` with a dedicated `error_code` so clients can show "processing" and retry, rather than prompting a re-upload that would destroy a perfectly good measurement. ```json theme={null} { "error_code": "MEASUREMENT_PROCESSING", "message": "This measurement's step data is still being processed and will be available shortly. No action is needed." } ``` Wait and retry. A measurement that is still reported as in-flight an hour after its last update is treated as stranded and reported as `MEASUREMENT_DATA_MISSING` instead, so a read never leaves you waiting on work that will never finish. ### Missing measurement data If a measurement's stored `steps.parquet` or `time_series.parquet` is missing or empty — a measurement uploaded before the platform enforced step-data integrity, or one whose processing was stranded — reading steps, filtering steps, or requesting a signed download URL returns `422 MEASUREMENT_DATA_MISSING` rather than an empty result, so the failure is obvious instead of silently propagating into fits and simulations. A plain time-series read (fetching the measurement detail with its time series, or the dedicated time-series endpoint) is the exception: if the underlying `time_series.parquet` can't be loaded it surfaces as a generic `500` (`INTERNAL_ERROR`), not `422`. Only the step, step-filter, and signed-download-URL paths raise `MEASUREMENT_DATA_MISSING`. ```json theme={null} { "error_code": "MEASUREMENT_DATA_MISSING", "message": "This measurement's step data is missing or empty. Please re-upload the entire measurement to regenerate it." } ``` The fix is always the same: re-upload the entire measurement (see [uploading data](/data/uploading)). Re-uploading regenerates both `steps.parquet` and `time_series.parquet` from the source time series. This error is distinct from a transient `502` storage failure — retrying the same request will not recover the data, so the client should surface the re-upload prompt rather than backing off. ## Full API reference For the complete Python API reference, see the [ionworks-api documentation](https://api.docs.ionworks.com/). ## Next steps Explore uploaded data with the interactive in-browser viewer. End-to-end upload workflow for specs, instances, and measurements. The three measurement types in detail. Run simulations and pipelines via the Python API. # Uploading data Source: https://docs.ionworks.com/data/uploading Upload cell specifications, instances, and measurements to Ionworks Studio via the Python API Uploading data to Ionworks Studio follows a three-step hierarchy: create the cell specification (the cell's blueprint), create a cell instance (a specific physical cell), then upload measurements against that instance. For installation and authentication, see the [Python API client](/api-client) page. For reading data back, see [reading data](/data/reading). ## Upload workflow The spec defines the cell's materials, electrical ratings, and components. A specific physical cell, linked to a spec. Attach time series, properties, or file measurements to the instance. ### Step 1: Create or get a cell specification ```python theme={null} cell_spec = client.cell_spec.create_or_get( { "name": "NCM622/Graphite Coin Cell", "form_factor": "R2032", "manufacturer": "Custom Cells", "ratings": { "capacity": {"value": 0.002, "unit": "A.h"}, "voltage_min": {"value": 2.5, "unit": "V"}, "voltage_max": {"value": 4.2, "unit": "V"}, }, "cathode": { "properties": {"loading": {"value": 12.3, "unit": "mg.cm-2"}}, "material": {"name": "NCM622", "manufacturer": "BASF"}, }, "anode": { "properties": {"loading": {"value": 6.5, "unit": "mg.cm-2"}}, "material": {"name": "Graphite", "manufacturer": "Customcells"}, }, } ) ``` See [Cells](/core-concepts/cells) for the full set of cell specification fields. ### Step 2: Create or get a cell instance ```python theme={null} cell_instance = client.cell_instance.create_or_get( cell_spec.id, { "name": "NCM622-GR-001", "batch": "BATCH-2024-001", "date_manufactured": "2024-01-20", "measured_properties": { "cathode": {"loading": {"value": 12.1, "unit": "mg.cm-2"}}, "anode": {"loading": {"value": 6.4, "unit": "mg.cm-2"}}, }, }, ) ``` Cell instance fields: | Field | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------ | | `name` | Unique identifier for this cell | | `batch` | Batch or lot number | | `date_manufactured` | Manufacturing date (optional) | | `measured_properties` | Cell-specific measured values that differ from the spec (e.g., actual measured capacity, electrode loadings) | ### Step 3: Upload a measurement with time series ```python theme={null} import pandas as pd time_series = pd.DataFrame( { "Time [s]": [0, 1, 2, 3, 4, 5], "Voltage [V]": [3.0, 3.2, 3.5, 3.8, 4.0, 4.2], "Current [A]": [-0.002, -0.002, -0.002, -0.002, -0.002, -0.002], "Step count": [0, 0, 0, 1, 1, 1], "Cycle count": [0, 0, 0, 0, 0, 0], "Step from cycler": [1, 1, 1, 2, 2, 2], "Cycle from cycler": [0, 0, 0, 0, 0, 0], } ) measurement_data = { "measurement": { "name": "Formation Cycle 1", "protocol": { "name": "CC-CV charge at C/10 to 4.2V", "ambient_temperature_degc": 25, }, "test_setup": { "cycler": "Biologic VMP3", "operator": "Jane Smith", }, "notes": "Formation cycle - first charge", }, "time_series": time_series, } bundle = client.cell_measurement.create(cell_instance.id, measurement_data) print(f"Created measurement: {bundle.name}") print(f"Steps created: {bundle.steps_created}") ``` For **properties** and **file** measurement types (and the fields they accept), see [measurements](/data/measurements). ## Upload validation Before any data is sent to Ionworks Studio, the Python client validates your time series on your machine. The always-on checks catch the most common processing mistakes: * Positive current corresponds to discharge (voltage decreases); charging uses negative current * Time starts at 0 and is monotonically non-decreasing * `Step count` exists, starts at 0, and increments by 1 * Cumulative columns (capacity, energy) reset at the start of each step If any of these fail, `create` and `create_or_get` raise a `MeasurementValidationError` and no upload is initiated. See [Troubleshooting](#troubleshooting) below for how to fix each failure. ### Inspecting validation failures `MeasurementValidationError.errors` is a list of `ValidationIssue` records. Each issue carries a stable `code` (an `IssueCode` enum), a `severity` (`"error"` or `"warning"`), a human-readable `message`, and a structured `payload` dict with details such as step indices, column names, observed values, and thresholds. Branch on `code` rather than parsing `message` — wording can change between releases, but check identifiers are stable. ```python theme={null} from ionworks import IssueCode, MeasurementValidationError try: client.cell_measurement.create(cell_instance.id, measurement_data) except MeasurementValidationError as e: for issue in e.errors: print(f"[{issue.severity}] {issue.code}: {issue.message}") print(" details:", issue.payload) if e.has_code(IssueCode.CURRENT_SIGN_REVERSED): # Auto-fixable: positive current was charge, not discharge. # Use ionworksdata.transform.set_positive_current_for_discharge(df) # before re-uploading. ... ``` ### Strict validation **We recommend passing `validate_strict=True` on every upload.** The strict checks catch subtle bugs introduced during data processing — for example, large unrecorded time gaps or a time series whose rows have been reordered by a faulty `groupby` or `partition_by` call — before the data lands in Ionworks Studio and contaminates downstream simulations and fits. ```python theme={null} bundle = client.cell_measurement.create( cell_instance.id, measurement_data, validate_strict=True, ) ``` Strict mode adds the following checks on top of the always-on ones: * Each step contains at least 2 data points * `Cycle count` is constant within each step * No gap between consecutive time samples exceeds 5 hours * Voltage is continuous between consecutive rows (requires `voltage_window`) * No two consecutive same-direction steps deliver a full charge or discharge (requires `rated_capacity` and a `steps` dataframe in `measurement_detail`) * No single step exceeds 500 % of the rated capacity — emitted as a `UserWarning` rather than raising (requires `rated_capacity` and `steps`) * Reported cumulative capacity and energy columns agree with the trapezoidal integral of current (and power) within 10 % at every row — catches mislabeled `Discharge`/`Charge` columns, dropped samples, and sign-convention bugs that survive the always-on checks ### Providing cell context for richer checks The voltage-continuity and step-capacity checks need to know the cell's rated voltage window and capacity. Pass them as keyword arguments: ```python theme={null} bundle = client.cell_measurement.create( cell_instance.id, measurement_data, validate_strict=True, rated_capacity=0.002, # A.h — nominal capacity of the cell voltage_window=(2.5, 4.2), # (V_min, V_max) from the cell spec ) ``` If you already have the cell spec object, you can read these values directly from it: ```python theme={null} ratings = cell_spec.ratings rated_capacity = ratings["capacity"]["value"] voltage_window = ( ratings["voltage_min"]["value"], ratings["voltage_max"]["value"], ) ``` Both arguments are optional. Omitting `voltage_window` skips only the voltage-continuity check; omitting `rated_capacity` skips only the consecutive-full-step and per-step capacity checks. All other strict-mode checks still run. The consecutive-full-step and per-step capacity checks also require a pre-computed step summary under the `steps` key of `measurement_detail`. If you do not provide one, Ionworks Studio generates step summaries server-side after upload, but these two client-side checks are skipped. `create_or_get` accepts the same `rated_capacity` and `voltage_window` arguments and forwards them to `create`. ### Relaxing a single strict check If a specific strict check is a known false positive for your dataset — for example, a legitimate multi-day rest period that exceeds the 5-hour time-gap threshold — relax **only that check** with `skip_checks` rather than turning strict mode off entirely. This keeps every other guardrail in place (least-privilege validation). ```python theme={null} bundle = client.cell_measurement.create( cell_instance.id, measurement_data, validate_strict=True, skip_checks={"time_gaps"}, # everything else still enforced ) ``` Valid names (also exposed as `ionworks.validators.STRICT_CHECK_NAMES`): * `minimum_points_per_step` * `cycle_constant_within_step` * `time_gaps` * `voltage_continuity` * `consecutive_same_direction_full_steps` * `step_capacity_within_rated` * `capacity_energy_from_current_power` Unknown names raise `ValueError`. Avoid disabling strict mode wholesale unless you have a documented reason that applies to multiple checks at once. ## Server-side step generation After the client-side checks pass and the time series reaches the server, Ionworks Studio generates a per-step summary (`steps.parquet`) from the upload. Every measurement must end up with at least one valid step — if it does not, the upload is rejected and the server attempts to roll back the measurement record and any uploaded storage files so partial data is not left behind. Rollback is best-effort — if a storage-cleanup step fails it is logged and the original error still surfaces, so on rare occasions an orphaned `time_series.parquet` can remain in storage. The server returns `400 BAD_REQUEST` in two cases: * **No steps could be generated.** The time series produced zero steps — typically because `Step count` is missing, never increments, or the file contains no rows of cycler step data. > No steps could be generated from the uploaded time series. The > measurement must contain at least one step; check that the file holds > valid cycler step data before uploading. * **A step is missing required column values.** Summarization produced rows, but one of them failed schema validation — most often because a required column such as `Step from cycler` is missing or contains an unparseable value. The error names the offending step and columns: > Step 7 could not be read from the uploaded time series: invalid or > missing value(s) for Step from cycler. Ensure the file includes valid > values for these step columns before uploading. Both errors are raised as `IonworksError` from the Python client. Fix the source time series — usually by re-running your reader against the raw cycler file or by populating the named column — and re-upload. These checks complement, not replace, the client-side [upload validation](#upload-validation). The client catches malformed time series before any network call; the server-side checks catch cases where the file is structurally valid but cannot be summarized into steps. ### EIS measurement validation Cycling checks don't apply to [electrochemical impedance spectroscopy (EIS)](/data/format#eis-and-impedance-data) data — an EIS spectrum has no `Time [s]` or `Step count`, just impedance samples across frequency. Tell the client that a measurement is EIS by adding a `data_type` hint to the measurement dict, and the validator switches to an EIS-specific set of checks. ```python theme={null} measurement_data = { "measurement": { "name": "EIS at 25°C, 50% SOC", "data_type": "eis", "protocol": { "ambient_temperature_degc": 25, }, }, "time_series": eis_df, # columns: Frequency [Hz], Z_Re [Ohm], Z_Im [Ohm] } bundle = client.cell_measurement.create( cell_instance.id, measurement_data, validate_strict=True, rated_capacity=0.002, # A.h — enables the impedance-magnitude check ) ``` The following checks run: * **Structural columns** (always on) — the frame must contain `Frequency [Hz]`, `Z_Re [Ohm]`, and `Z_Im [Ohm]`. * **`eis_zim_sign`** (strict-only) — flags a spectrum whose capacitive band is predominantly positive. The Ionworks convention is that `Z_Im [Ohm]` is **negative** across the capacitive arc (see [EIS and impedance data](/data/format#eis-and-impedance-data)); the fix hint is `Z_Im = -Z_Im`. * **`eis_impedance_magnitude`** (strict-only) — catches order-of-magnitude unit errors (Ω vs. mΩ vs. kΩ) by comparing `min(Z_Re)` against a wide 100× band around a capacity-derived estimate. Requires `rated_capacity`; skipped if not supplied. Both heuristic checks fire only on order-of-magnitude issues — a fully flipped sign, or a 1000× unit error — so a plausible spectrum is never touched. Skip an individual check the same way as any other strict check: ```python theme={null} bundle = client.cell_measurement.create( cell_instance.id, measurement_data, validate_strict=True, skip_checks={"eis_zim_sign"}, ) ``` ## Idempotent uploads with `create_or_get` All three create methods have `create_or_get` variants that make upload scripts safely re-runnable — if a resource with the same name already exists, the client fetches and returns it instead of raising an error. ```python theme={null} # cell_spec: data only spec = client.cell_spec.create_or_get(data) # cell_instance: requires cell_spec_id instance = client.cell_instance.create_or_get(cell_spec_id, data) # cell_measurement: requires cell_instance_id measurement = client.cell_measurement.create_or_get(cell_instance_id, data) ``` ## Duplicate handling When you call `create()` (not `create_or_get`) and a resource with the same name already exists, the API returns a `409 Conflict` with the existing resource's ID in the `detail` field: ```json theme={null} { "error_code": "CONFLICT", "message": "Cell specification with this name already exists", "detail": { "resource_type": "cell_specification", "resource_name": "My Cell Spec", "existing_id": "abc-123" } } ``` The `existing_id` lets you fetch the existing record without a separate lookup if you want to implement your own create-or-get logic. ## Inline time series size limit Time series passed inline (e.g. inside a pipeline configuration) are capped at **1,000 rows** — larger datasets must be uploaded as measurements first. See [reading data — inline size limit](/data/reading#inline-time-series-size-limit) for the error class, workaround, and `db:` reference form. ## Automatic payload compression The Python client automatically compresses request payloads larger than 512 KB using gzip before sending them to the API. This happens transparently — no configuration is needed. For large serialized models or datasets, this can reduce upload sizes significantly. ## Extending an unfinished measurement A measurement that is still being recorded can be uploaded incrementally. `extend()` appends new rows to a measurement whose `end_time` is not yet set, so a long test can be pushed as it runs instead of waiting for it to finish and re-sending the whole series. ```python theme={null} result = client.cell_measurement.extend(measurement_id, new_rows) ``` Pass **only the new rows**, not the full series: * The delta's first `Time [s]` must come after the last existing sample — overlapping rows are rejected. * Its columns must match the existing series exactly. * Never pass steps. Steps and cycles are recomputed from the stitched series after each extend. Once the test is finished, set `end_time` with `client.cell_measurement.update()`. A measurement with an `end_time` is closed and can no longer be extended. Stitching happens after the upload is confirmed. `extend()` waits for it and returns the updated measurement; pass `wait_for_processing=False` to return as soon as the upload is accepted and poll yourself. A measurement that is still growing changes what a pipeline run reads. To keep re-runs comparable, pin the run to a fixed window with `time_range` — see [reading data](/data/reading). ## Uploading very large files Files above 512 MiB upload in multiple parts rather than a single request, which makes multi-gigabyte raw files and time series upload reliably. This path needs an optional dependency: ```bash theme={null} pip install 'ionworks-api[large-uploads]' ``` Without the extra, files up to 1 GiB still upload via the single request. Above that, the extra is required. `client.raw_data.create()` accepts a `timeout` — either a `(connect, read)` pair or one value for both — for a slow or unreliable connection. On the multi-part path it applies per part, not to the whole transfer. ## Troubleshooting ### Large time gap between consecutive rows **Problem:** When uploading with `validate_strict=True`, you receive an error like: > Time gap of 6.12 h between rows 4201 and 4202 exceeds the maximum allowed > gap of 5.0 h. **Solution:** A gap longer than 5 hours between two consecutive time samples almost always means rows were dropped during processing — for example, a long rest period was recorded as elapsed time in the file but the intermediate rows were stripped. The capacity integral then counts current across the missing interval and reports inflated values. Re-read the raw cycler file without filtering and confirm the time axis is continuous. If your workflow intentionally decimates the data, downsample uniformly rather than removing whole sections, or split the recording into separate measurements at the gap. ### Voltage continuity check failed **Problem:** When uploading with `validate_strict=True` and a `voltage_window`, you receive an error like: > Voltage continuity check failed: 318/3599 (8.8%) of consecutive row pairs > have a voltage jump greater than 80% of the rated voltage window. **Solution:** Ionworks looks at the absolute voltage change between every pair of consecutive rows. If more than 5 % of pairs exceed 80 % of the rated voltage window (`V_max - V_min`), the time series is almost certainly out of chronological order — typically because of a grouping operation like `partition_by("Cycle_raw")` that interleaves pulse and rest rows from different cycles. Sort your DataFrame by `Time [s]` before uploading, and avoid any transform that breaks the natural row order: ```python theme={null} import polars as pl time_series = time_series.sort("Time [s]") ``` ### Consecutive full-capacity steps in the same direction **Problem:** When uploading with `validate_strict=True`, a `steps` dataframe, and a `rated_capacity`, you receive an error like: > Consecutive full-capacity discharge steps detected: step 12 and step 15 > each delivered more than 200% of the rated capacity. **Solution:** "Consecutive" here means the two nearest same-direction steps in the sequence — so step 12 and step 15 are consecutive discharges even if charge steps sit between them. A single measurement should represent one continuous test on a single cell, so two back-to-back full-capacity discharges (or charges) usually mean the file was assembled by concatenating several independent experiments (for example, multiple CC discharges at different C-rates stitched together). Split the source data back into separate measurements before uploading. If a legitimately long single experiment contains more than two full charges or discharges in a row — rare, but possible — drop the pre-computed `steps` entry from `measurement_detail` or omit `rated_capacity` so this specific check is skipped, and let Ionworks Studio regenerate step summaries server-side. ### Step capacity exceeds rated capacity **Problem:** When uploading with `validate_strict=True`, a `steps` dataframe, and a `rated_capacity`, you see a `UserWarning` like: > 3 step(s) exceed 500% of the rated capacity. First: step 47 has > 'Discharge capacity \[A.h]' = 0.031 A.h. **Solution:** This is a soft warning — the upload still proceeds — but it signals that something is probably wrong with your step boundaries or that the capacity integral was inflated across an unrecorded time gap. Inspect the flagged step, verify it represents a single charge or discharge, and re-check the `Step count` column for gaps. Fixing the underlying time-gap or step boundary issue usually clears the warning. ### Capacity or energy column disagrees with the current/power integral **Problem:** When uploading with `validate_strict=True`, you receive an error like: > Column 'Discharge capacity \[A.h]' disagrees with the running integral of > 'max(I, 0)' over time by up to 47.3% (exceeds 10% tolerance). Worst row > 8421: reported = 0.0019 A.h, integrated = 0.0010 A.h. This check compares each reported cumulative column against the trapezoidal integral of current (capacity) or power (energy) over time, with a per-step reset that mirrors how the platform reports cumulative columns. With the Ionworks sign convention (positive current = discharge): * `Discharge capacity [A.h]` ≈ `∫ max(I, 0) dt / 3600` * `Charge capacity [A.h]` ≈ `∫ max(-I, 0) dt / 3600` * `Discharge energy [W.h]` ≈ `∫ max(P, 0) dt / 3600` * `Charge energy [W.h]` ≈ `∫ max(-P, 0) dt / 3600` Power is taken from `Power [W]` if present, otherwise computed as `Voltage [V] * Current [A]`. The corresponding issue codes are `DISCHARGE_CAPACITY_INTEGRAL_MISMATCH`, `CHARGE_CAPACITY_INTEGRAL_MISMATCH`, `DISCHARGE_ENERGY_INTEGRAL_MISMATCH`, and `CHARGE_ENERGY_INTEGRAL_MISMATCH`. Each issue's `payload` carries the worst-row index, the reported and integrated values at that row, the max cumulative scale, and the relative error. **Solution:** The mismatch usually traces back to swapped `Discharge` / `Charge` labels, a wrong current sign convention, or unreliable source columns. All three are covered by the transform recipes in [preparing data — swapped charge and discharge columns](/data/preparing-data#swapped-charge-and-discharge-cumulative-columns) and the [sign-convention troubleshooting](/data/preparing-data#incorrect-current-sign-convention) above it — fix the data there and re-upload. If the discrepancy is instead a known artefact of your dataset and you have verified it does not affect downstream analyses, relax just this check with `skip_checks={"capacity_energy_from_current_power"}`. ## Next steps The three measurement types — time series, properties, file — in detail. List, filter, paginate, and retrieve measurement data. Read cycler files into the Ionworks format before uploading. Recognized columns, quantity format, and sign conventions. # Visualizing data Source: https://docs.ionworks.com/data/visualizing Plot battery cycling data in Ionworks Studio with stack-cycles, Nyquist EIS, differential dV/dQ and dQ/dV, cycle metrics, and AI-powered SQL step filters Ionworks Studio provides interactive tools for exploring your uploaded battery cycling data. The measurement viewer offers plots with flexible filtering options to help you analyze your experiments in the browser. For plotting from Python, see [reading data — plotting from Python](/data/reading#plotting-from-python). ## Accessing the data viewer To view measurement data: 1. Navigate to your **Cell Specification** 2. Select a **Cell Instance** 3. Click on a **Cell Measurement** 4. The measurement detail view opens with visualization tools ## Visualization tabs The data viewer has tabs for different analysis perspectives: ### Time series tab The time series view shows high-resolution measurement data over time (or other x-axis variables). #### Axis selection Use the dropdown menus to select which variables to plot on the x-axis and y-axis. Available options depend on the columns present in your measurement data. A derived **`Power [W]`** column (voltage × current) appears in the axis dropdown for any time-series measurement that carries both `Voltage [V]` and `Current [A]`, even when the source data did not include a power column — Ionworks computes it when the measurement is processed and stores it alongside the other channels. A measurement missing either source column gets no power column. Select it like any other variable to plot power over time or against another signal. #### Secondary y-axis Optionally add a secondary y-axis to overlay two variables on the same plot. Select "None" to disable. The secondary y-axis is disabled when "Stack cycles" mode is active. #### Overlaying multiple series with the same unit Both the left and right y-axes accept multiple variables, as long as the additional variables share the same unit as the primary variable on that axis. This is useful for direct visual comparison of related signals — for example, plotting `Voltage [V]` together with `Anode potential [V]` and `Cathode potential [V]` on the same axis. To overlay additional series: 1. Pick the primary variable for the axis (left or right) as usual. 2. Click **Add series with same unit** under that axis. 3. Use the new dropdown to choose any other variable with a matching unit. 4. Repeat to stack more series, or click the **×** button next to a series to remove it. Each additional series is drawn with its own color so individual traces remain distinguishable. The **Add series with same unit** button only appears when at least one other available variable shares the primary variable's unit; changing the primary variable to a different unit automatically clears the extra series on that axis. #### Stack cycles mode Enable **Stack cycles** to overlay all cycles on top of each other by resetting the x-axis to zero at the start of each cycle. This is useful for: * Comparing cycle-to-cycle variations * Identifying degradation patterns * Visualizing capacity fade When enabled, cycles are color-coded from blue (early cycles) to yellow (late cycles) using a colorblind-safe palette. #### Show step data Enable **Show step data** to display detailed step information in hover tooltips. The tooltip shows: * Cycle and step numbers * Step metrics (duration, capacity, voltage stats) * SQL column names for use in filters Use "Show step data" to discover the column names you need for SQL filtering. The hover tooltip displays the exact column names used in filter queries. ### EIS tab When your measurement contains impedance data (`Z_Re [Ohm]`, `Z_Im [Ohm]`, and `Frequency [Hz]` columns), an **EIS** tab appears automatically. This tab displays a Nyquist plot with `Z_Re [Ohm]` on the x-axis and `-Z_Im [Ohm]` on the y-axis. If your data contains multiple steps, each step is plotted as a separate trace with its own color. You can use the step filter to select which steps to display. ### Differential tab The **Differential** tab shows derivative curves computed from the measurement's voltage and capacity data. It's the standard view for **incremental capacity analysis (ICA)** and **differential voltage analysis (DVA)** — techniques used to identify electrode phase transitions, quantify loss of active material, and diagnose degradation modes. The tab appears automatically for any `time_series` measurement that has `Voltage [V]`, `Cycle count`, and a supported capacity column (`Charge capacity [A.h]` + `Discharge capacity [A.h]`, `Net capacity [A.h]`, or `Capacity [A.h]`). You can switch between two curve types using the toggle at the top of the tab: | Curve | Axes | Typical use | | ------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `dV/dQ` | Capacity (x) vs. `dV/dQ` (y) | Differential voltage analysis — flat regions on the V–Q curve become peaks, making stoichiometric windows easy to compare across cycles. | | `dQ/dV` | Voltage (x) vs. `dQ/dV` (y) | Incremental capacity analysis — plateaus on the V–Q curve become peaks aligned with phase transitions in the electrodes. | Each cycle in the measurement is plotted as its own trace so you can track how peaks shift over cycling. Use the half-cycle control to compare charge and discharge data, and the cycle filter to compare early- and late-life behavior on the same axes. Pair the Differential tab with the [weighted point-cloud dQ/dV objective](/build/parameterize/data-fitting/objective-functions#wasserstein-weighted-point-cloud-mode) when fitting a model — matching peak positions is often a more sensitive target than matching the raw V–Q curve. ### Cycles tab The cycles view shows aggregated metrics per cycle, ideal for tracking degradation and performance trends. #### Available cycle metrics | Metric | Description | | --------------------------- | ---------------------------------- | | `Discharge capacity [A.h]` | Total discharge capacity per cycle | | `Charge capacity [A.h]` | Total charge capacity per cycle | | `Discharge energy [W.h]` | Total discharge energy per cycle | | `Charge energy [W.h]` | Total charge energy per cycle | | `Coulombic efficiency` | Discharge/charge capacity ratio | | `Energy efficiency` | Discharge/charge energy ratio | | `Capacity throughput [A.h]` | Cumulative capacity processed | | `Energy throughput [W.h]` | Cumulative energy processed | | `Min voltage [V]` | Minimum voltage reached in cycle | | `Max voltage [V]` | Maximum voltage reached in cycle | | `Cycle duration [s]` | Total cycle time | | `Mean temperature [degC]` | Average temperature during cycle | #### Relative / retention mode For capacity and energy metrics, enable **Show relative** to display values as a fraction of a reference cycle: 1. Check "Show relative" 2. Enter the reference cycle number (e.g., cycle 1 or cycle 10) 3. Values are shown as `value / reference_value` This is useful for visualizing capacity retention curves (e.g., 80% retention after 500 cycles). ## Filtering data The measurement viewer provides multiple filtering methods to focus on specific portions of your data. ### Cycle filter Filter which cycles are displayed using several input methods: Enter a custom filter expression using Python slice-like syntax: * Single cycles: `1, 5, 10, 20` * Range: `0:50` (cycles 0 through 50) * Negative indexing: `-10:-1` (last 10 cycles) * Combined: `0:10, 50, 100:110` Quickly select the first N cycles. Enter the count and the filter automatically generates. Select the last N cycles from your dataset. Define a range with optional sampling: * **Min**: Starting cycle (default: 0) * **Max**: Ending cycle (default: last cycle) * **Step**: Sample every Nth cycle (e.g., step=10 shows cycles 0, 10, 20...) Use stepping for large datasets to reduce plot density while maintaining overall trends. ### Step filter (within cycle) Filter which steps within each cycle are shown. This is useful for isolating specific protocol phases: * `0` - Show only the first step of each cycle * `0:2` - Show steps 0, 1, and 2 of each cycle * `1, 3, 5` - Show specific steps Step numbers reset to 0 at the start of each cycle in this filter. So step 0 is always the first step of a cycle, regardless of the overall step count. This filter is only available when not using Advanced mode. ### Advanced step filter (AI-powered) The Advanced filter mode lets you filter steps using natural language or SQL. Simply describe what you want to see, and the AI generates the appropriate filter. #### Using advanced filter mode Choose "Advanced" from the filter type dropdown. This is the default when no filter is active. Type a natural language description in the text field. For example: * "discharge steps longer than 1 hour" * "charge steps with capacity > 2 Ah" * "rest steps" The AI generates the SQL filter and applies it immediately. The plot updates to show only matching steps. #### Refining your filter When a filter is active, you can modify it with follow-up prompts: * "also filter for steps longer than 1 hour" → adds to existing filter * "change to charge steps instead" → replaces part of the filter * "remove the duration filter" → removes a specific condition #### SQL dialog (advanced editing) Click the code icon `` to open the SQL dialog for more control: * **AI prompt field**: Describe what you want, then click "Generate" * **SQL editor**: Edit the SQL WHERE clause directly * **Match mode**: Choose how to apply the filter (see below) * **Example queries**: Click any example to insert it * **Available columns**: Full reference of filterable columns #### Match modes When filtering, choose how matches are applied: * **Matching steps only**: Show only the specific steps that match your query * **Full cycles**: Show all steps from cycles that contain at least one matching step Use "Full cycles" mode when you want to filter by a cycle characteristic (like total cycle discharge capacity) but still see the complete cycle data for context. #### Available SQL columns The SQL dialog shows the complete list of available columns. Common columns include: **Step identification:** * `step_count` - Overall step number * `cycle_count` - Cycle number * `step_from_cycler` - Original step number from cycler * `cycle_from_cycler` - Original cycle number from cycler **Timing:** * `duration_s` - Step duration in seconds **Capacity (step level):** * `charge_capacity_ah` - Capacity charged in this step * `discharge_capacity_ah` - Capacity discharged in this step **Energy (step level):** * `charge_energy_wh` - Energy charged in this step * `discharge_energy_wh` - Energy discharged in this step **Capacity (cycle level, cumulative):** * `cycle_charge_capacity_ah` - Total charge capacity for the cycle * `cycle_discharge_capacity_ah` - Total discharge capacity for the cycle **Voltage:** * `start_voltage_v`, `end_voltage_v` * `min_voltage_v`, `max_voltage_v`, `mean_voltage_v` **Current:** * `min_current_a`, `max_current_a`, `mean_current_a` **Power:** * `min_power_w`, `max_power_w`, `mean_power_w` ## Plot interactions The visualization plots support standard interactions: * **Zoom**: Click and drag to zoom into a region * **Pan**: Hold shift and drag to pan * **Reset**: Double-click to reset the view * **Hover**: Move cursor over data points to see values ### Automatic data loading When you zoom into the time series plot, Ionworks Studio automatically fetches higher-resolution data for the zoomed region. A loading indicator appears while data is being fetched. For large datasets, the initial view shows downsampled data for performance. Zooming in reveals the full resolution data for that time range. ## Best practices Begin with the full dataset view to understand overall trends, then use filters to focus on specific cycles or conditions of interest. Stack cycles mode makes it easy to spot changes in voltage profiles, capacity, or other characteristics as the cell ages. Use cycle filtering to select a range of cycles, then add Advanced filtering to isolate specific step types within those cycles. The Advanced filter understands plain English. Try "discharge steps over 1 hour" or "cycles with high capacity" instead of writing SQL manually. Enable "Show step data" and hover over your data to see step metrics and the SQL column names you can reference in filters. ## Next steps Understand the data structure for better filtering. Retrieve and analyze data via the Python API. # Battery Capacity Source: https://docs.ionworks.com/guide/batteries-101/battery-capacity Nominal capacity, C-rate specific capacity, and usable capacity explained, with how each is measured in ampere-hours. Battery capacity quantifies how much charge a battery can store and deliver—making it a critical metric for applications ranging from smartphones to electric vehicles. While the intuitive definition of capacity may seem straightforward, there are multiple ways to measure and report it. ## Types of Capacity ### Nominal Capacity The most common definition of capacity is **nominal capacity**, the value typically provided by the battery's manufacturer. This is the amount of charge (measured in ampere-hours, Ah) the battery is designed to deliver under standardized test conditions. Nominal capacity serves as a benchmark for comparing different batteries, but it represents an idealized value—hence it's often reported as a round number. Real-world conditions (temperature fluctuations, aging, faster discharge rates) usually result in lower usable capacity. ### C-Rate Specific Capacity The **C-rate** defines how quickly a battery is charged or discharged relative to its nominal capacity. A C-rate of 1C means the battery would be fully discharged in 1 hour; 2C means 30 minutes; C/2 (or 0.5C) means 2 hours. The current corresponding to a given C-rate is: $I = C\text{-rate} \times Q_{nom}$ For example, a 50 Ah battery at 2C draws $2 \times 50 = 100$ A. | C-Rate | Discharge Time | Typical Application | | ------ | -------------- | ---------------------------------- | | C/20 | 20 hours | Capacity testing, characterization | | C/3 | 3 hours | Standard capacity measurement | | 1C | 1 hour | Typical EV discharge | | 2C | 30 minutes | Fast discharge, high power | ### Measuring Capacity at a Specific C-Rate To measure capacity at a given C-rate: 1. Fully charge the battery using the manufacturer's recommended protocol 2. Rest until voltage stabilizes (typically 1-4 hours) 3. Discharge at constant current corresponding to the desired C-rate 4. Stop when the lower voltage cutoff is reached 5. The capacity equals the integrated current over time As the C-rate increases, kinetic and transport limitations become significant, leading to lower measured capacities. Always report the C-rate alongside capacity measurements to enable fair comparisons. Rate capability plot This definition is particularly important for assessing capacity fade due to degradation, where capacity is typically measured at a reference C-rate (commonly C/3 or C/5) to track changes over time. ### Theoretical Capacity If we could discharge a battery infinitely slowly, we would obtain the theoretical (or thermodynamic) capacity. This is the maximum charge a battery can deliver within a given voltage window (e.g., 4.2 V to 2.5 V) when discharged infinitely slowly, eliminating all kinetic and transport limitations. In practice, we cannot discharge a battery infinitely slowly, so we approximate the theoretical capacity by discharging at a very low C-rate. This will always result in an underestimation of the true theoretical capacity. ## Related Topics * [State of Charge](/guide/batteries-101/state-of-charge)—measuring remaining charge relative to capacity * [State of Health](/guide/batteries-101/state-of-health)—how capacity fades over time * [Internal Resistance](/guide/batteries-101/internal-resistance)—why capacity decreases at high C-rates * [Reaction Kinetics](/guide/batteries-101/reaction-kinetics)—the physics behind rate-dependent capacity loss * [Degradation Overview](/guide/batteries-101/degradation)—mechanisms that cause capacity fade # Battery Management Systems Source: https://docs.ionworks.com/guide/batteries-101/battery-management-systems What a BMS does: protection, SoC and SoH estimation, and cell balancing for safe and optimized battery pack operation. The **Battery Management System (BMS)** is the electronic "brain" that monitors and controls the battery pack. Its primary responsibility is to maintain safety while optimizing the life and performance of the cells. ## Core Functions Ensures the battery never operates outside safe limits for voltage, current, and temperature. Calculates key metrics (State of Charge, State of Health, State of Power, State of Energy) using sensor data and models. Keeps cells at similar states of charge to maximize pack capacity. ## State Estimation The BMS performs real-time calculations to inform the user and control systems: | State | Description | | ------- | ------------------------------------------------------------------------------------------------------------------- | | **SoC** | [State of Charge](/guide/batteries-101/state-of-charge)—how much charge remains | | **SoH** | [State of Health](/guide/batteries-101/state-of-health)—how much the battery has degraded | | **SoP** | [State of Power](/guide/batteries-101/state-of-charge#state-of-power-sop)—maximum charge/discharge power available | | **SoE** | [State of Energy](/guide/batteries-101/state-of-charge#state-of-energy-soe)—remaining energy considering efficiency | See the [State of Charge](/guide/batteries-101/state-of-charge) and [State of Health](/guide/batteries-101/state-of-health) pages for more details on these metrics and how they are estimated. ## Protection Functions The BMS must protect against several failure modes. These include overvoltage (which can cause electrolyte decomposition), undervoltage (which can dissolve copper from the current collector), overcurrent (causing rapid heating), and over-temperature (accelerating degradation). In high-voltage systems, isolation faults also pose electric shock risks. These failure modes are interconnected—for example, overcurrent leads to heating, which can trigger thermal runaway if unchecked. The BMS must monitor all conditions simultaneously. ### Temperature Monitoring The BMS monitors temperature sensors distributed throughout the pack and can: * Command the cooling system to increase flow * De-rate power output if cells exceed safe limits (typically above 55°C) * Limit or prevent charging at low temperatures (typically below 0°C to 10°C depending on chemistry) to avoid lithium plating ### Isolation Monitoring In high-voltage systems (400V or 800V), the BMS monitors insulation resistance between the battery terminals and the vehicle chassis. If a fault is detected, it opens the contactors to prevent electric shock. ## Cell Balancing In a series-connected pack, cells inevitably drift apart in their SoC because no two cells are identical. Manufacturing variations, temperature gradients across the pack, and differences in self-discharge rates all contribute to this drift. Without balancing: * A single "full" cell prevents the rest of the string from charging * A single "empty" cell forces the whole pack to stop discharging This means the pack's usable capacity is limited by its weakest cell, even if other cells have capacity remaining. ### Passive Balancing The simpler and more common approach. During charging, when a cell reaches its upper voltage limit before others, the BMS dissipates its excess energy through a resistor as heat. This allows charging to continue until all cells are full. **Limitations**: Only works during charge, wastes energy as heat, and balancing current is typically low (50-200 mA), requiring long charge times for significant imbalances. ### Active Balancing Uses DC-DC converters to transfer energy from higher-SoC cells to lower-SoC cells. This approach is more efficient and can operate during both charge and discharge. **Limitations**: Significantly more complex and expensive, requiring additional power electronics for each cell or group of cells. Passive balancing dominates in most commercial applications due to its simplicity and lower cost. Active balancing is used in high-performance or high-value applications where the efficiency gains justify the added complexity. ## BMS Architectures The physical implementation varies depending on the application scale: ### Centralized BMS A single controller connects to every cell in the pack. **Best for:** Small applications (e-bikes, power tools) **Pros:** Simple design, lower cost **Cons:** Complex wiring for large packs ### Distributed (Master-Slave) Architecture A "Master" controller communicates via a bus (like CAN) with multiple "Slave" boards or Cell Monitoring Units (CMUs). **Best for:** Large applications (EVs, grid storage) **Pros:** Reduced wiring complexity, improved reliability, modular **Cons:** More complex communication protocols ## Thermal Management Integration The BMS controls the thermal management system: * **Cooling**: Commands coolant pumps and fans based on cell temperatures * **Heating**: In cold climates, may activate heaters before allowing charge * **Thermal modeling**: Uses resistive losses ($I^2R$) to predict temperature evolution ## Safety Systems ### Contactors Heavy-duty switches that can isolate the battery in emergencies: * Open immediately upon crash signal from vehicle * De-energize high-voltage lines to prevent hazards * Controlled by the BMS based on fault conditions ### Fuses Provide backup protection if the BMS or contactors fail to respond to an overcurrent event. # Battery Packs Source: https://docs.ionworks.com/guide/batteries-101/battery-packs How cells are combined in series and parallel into modules and packs to deliver target voltage and capacity for EVs and storage. A single battery cell has limited voltage and capacity. To power applications like electric vehicles or grid storage systems, cells must be combined into larger assemblies. This page explains the hierarchy from cells to packs and the key considerations at each level. ## The Cell-to-Pack Hierarchy The fundamental electrochemical unit. A single cell typically provides 3-4V nominal voltage (depending on chemistry) and a fixed capacity (e.g., 50 Ah). Multiple cells connected in parallel to increase capacity while maintaining the same voltage. Cells or parallel groups connected in series to increase voltage while maintaining the same capacity. A mechanical assembly containing multiple cells, often with its own monitoring electronics and thermal management. The complete battery system including modules, the BMS, cooling system, contactors, and enclosure. ## Series vs Parallel Connections Cells connected positive-to-negative. **Voltages add**, capacity stays the same. Used to reach the system voltage requirement. Cells connected positive-to-positive and negative-to-negative. **Capacities add**, voltage stays the same. Used to increase energy and current capability. ### Notation: xSyP Battery configurations are described using **xSyP notation**: * **S** = number of cells in series * **P** = number of cells in parallel | Configuration | Meaning | Result (using 3.7V, 5Ah cells) | | ------------- | ---------------------- | ------------------------------ | | 4S1P | 4 cells in series | 14.8V, 5Ah | | 1S4P | 4 cells in parallel | 3.7V, 20Ah | | 4S2P | 4 series × 2 parallel | 14.8V, 10Ah | | 96S4P | 96 series × 4 parallel | 355V, 20Ah (typical EV) | ## Parallel Group Considerations When cells are connected in parallel, they share the same voltage, which causes them to naturally self-balance. This provides increased capacity and higher current capability since the load is shared across multiple cells. However, parallel groups require careful cell matching. Cells with different capacities or resistances will experience uneven current sharing—a cell with higher resistance carries less current and ages differently than its neighbors. Additionally, individual cell voltages cannot be monitored separately, making it harder to detect a failing cell. Most lithium-ion packs use a "parallel-first" configuration, where cells are first grouped in parallel, then these parallel groups are connected in series. This approach benefits from the self-balancing of parallel cells while achieving the required system voltage. ## Series String Considerations When cells are connected in series, their voltages add together while sharing the same current. This enables higher system voltages (reducing current for a given power level) and simplifies current measurement since only one sensor is needed. The main challenge with series connections is that cells drift apart in their state of charge over time due to manufacturing variations and temperature differences. Without intervention, a single cell reaching its voltage limit forces the entire string to stop—even if other cells have capacity remaining. The weakest cell in a series string determines pack performance. This is why [cell balancing](/guide/batteries-101/battery-management-systems#cell-balancing) is essential for series-connected packs. ## Module Design A **module** is a sub-assembly that groups cells together with: * **Mechanical structure**: Holds cells in place, often with compression * **Electrical connections**: Busbars connecting cells in the desired configuration * **Thermal interface**: Cooling plates or air channels * **Sensing**: Voltage taps and temperature sensors for the BMS ### Why Use Modules? | Benefit | Description | | --------------------- | ---------------------------------------------- | | **Manufacturability** | Easier to assemble and test smaller units | | **Serviceability** | Replace a module instead of the entire pack | | **Scalability** | Combine modules to create different pack sizes | | **Safety** | Contain thermal events within a module | ## Pack Architecture The complete **battery pack** integrates: | Component | Function | | ------------------ | ----------------------------------------------------------------------------------------- | | **Cells** | Store and release electrochemical energy | | **Modules** | Group cells mechanically and electrically | | **BMS** | Monitor and control the pack ([details](/guide/batteries-101/battery-management-systems)) | | **Thermal system** | Maintain cells within safe temperature range | | **Contactors** | High-voltage switches for isolation | | **Fuses** | Overcurrent protection | | **Enclosure** | Mechanical protection and sealing | | **Connectors** | High-voltage and communication interfaces | ### Example: Electric Vehicle Pack A typical EV battery pack might be configured as: ``` 96S4P configuration using 5Ah pouch cells: - 4 cells in parallel = 1 parallel group (5Ah × 4 = 20Ah) - 12 parallel groups in series = 1 module (44.4V nominal) - 8 modules in series = 1 pack (355V nominal, 20Ah, ~7 kWh) ``` ## Cell-to-Pack (CTP) Design Modern designs increasingly use **Cell-to-Pack (CTP)** architecture, which eliminates the module level: | Traditional | Cell-to-Pack | | -------------------------- | --------------------------------------- | | Cell → Module → Pack | Cell → Pack | | More structural components | Fewer components, higher energy density | | Easier serviceability | Lower cost, better space utilization | CTP designs use larger cells (often blade or prismatic format) that provide structural rigidity, reducing the need for intermediate module housings. ## Related Topics * [Battery Management Systems](/guide/batteries-101/battery-management-systems)—the electronic brain that monitors and controls the pack * [Thermal Modeling](/guide/batteries-101/thermal-modelling)—managing heat at the pack level * [State of Charge](/guide/batteries-101/state-of-charge)—estimating charge across cells in a pack * [Internal Resistance](/guide/batteries-101/internal-resistance)—how cell resistance affects pack performance # Degradation Overview Source: https://docs.ionworks.com/guide/batteries-101/degradation The main battery degradation mechanisms (SEI growth, lithium plating, loss of active material) and modes of capacity and resistance loss. Battery aging is not caused by a single factor but rather by a combination of degradation **mechanisms**—the physical and chemical processes that occur inside the cell—each affecting different components. These mechanisms manifest as degradation **modes** that can be measured and tracked. Understanding this distinction helps in diagnosing battery health and predicting remaining life. Degradation mechanisms ultimately lead to: * Loss of capacity * Increase in resistance ## Key Degradation Mechanisms The mechanisms below are among the most widely studied, though many other processes contribute to aging depending on chemistry and operating conditions. Each has its own page covering the underlying physics and how it is modeled—the cards here are brief summaries only, meant to place each mechanism within the taxonomy. Follow the links for the full detail. A protective layer forms on the anode surface. While essential for stable performance, it continues to grow over time, consuming lithium ions and increasing internal resistance. Under certain conditions, metallic lithium deposits on the anode surface instead of intercalating properly. This reduces available lithium and can form dangerous dendrites. Repeated expansion and contraction during cycling causes cracking of electrode particles and loss of electrical contact. ## Degradation Modes Regardless of the specific mechanism, degradation manifests as one of the following modes: ### Loss of Lithium Inventory (LLI) When lithium is consumed by unwanted side reactions—such as [SEI growth](/guide/batteries-101/sei) or [lithium plating](/guide/batteries-101/lithium-plating)—it becomes unavailable for energy storage. The electrodes may remain structurally intact, but with less cyclable lithium, the battery's capacity decreases. LLI is the dominant mode for calendar aging and is accelerated by high temperatures and high states of charge. ### Loss of Active Material in the Negative Electrode (LAMNE) When anode material becomes electrically isolated (due to particle cracking, binder degradation, or delamination), it can no longer participate in the electrochemical reactions. LAMNE is often caused by SEI-induced particle isolation or [mechanical cracking](/guide/batteries-101/mechanical-degradation) from volume changes during cycling. It is more prominent at high C-rates or with materials that experience large volume changes (like silicon). ### Loss of Active Material in the Positive Electrode (LAMPE) When cathode material becomes electrically isolated or structurally degraded, it can no longer store lithium. LAMPE can result from structural changes, transition metal dissolution, oxygen release, or mechanical stress. It is accelerated by high voltages and high temperatures. ### Resistance Increase In addition to capacity loss, degradation mechanisms also cause the cell's internal resistance to increase over time. This manifests as higher overpotentials during charge and discharge, reduced power capability, and increased heat generation. Resistance increase is caused by SEI thickening, loss of electrical contact, and electrolyte degradation. When quantifying degradation effects on theoretical capacity, we typically focus on **LLI**, **LAMNE**, and **LAMPE**—these three modes directly determine how much charge the battery can store. Resistance increase affects power and efficiency but not the theoretical capacity itself. ## Interaction of Degradation Modes In practice, these modes occur simultaneously and often reinforce each other. For example: * SEI growth (LLI) can increase local stresses that promote particle cracking (LAMNE) * Particle cracking (LAMNE) exposes fresh surface area that forms new SEI (LLI) * Lithium plating (LLI) can cause mechanical stresses that damage the anode (LAMNE) * Cathode structural changes (LAMPE) can release oxygen that accelerates electrolyte decomposition The relative contribution of each mode depends on battery chemistry, usage patterns, and environmental conditions. High-temperature storage primarily causes LLI through accelerated SEI growth, while aggressive cycling at low temperatures may cause both LLI (plating) and LAMNE (mechanical stress). ## Linking Mechanisms to Modes The table below shows some common examples of how mechanisms contribute to degradation modes. This is not comprehensive—many other mechanisms exist and the relationships depend on specific battery chemistry and operating conditions. See the pages on [SEI growth](/guide/batteries-101/sei), [lithium plating](/guide/batteries-101/lithium-plating), and [mechanical degradation](/guide/batteries-101/mechanical-degradation) for more details on specific mechanisms. | Mechanism | Primary Mode(s) | Accelerating Factors | | ---------------------- | ------------------------------------- | ------------------------------------------ | | SEI growth | LLI, Resistance increase | Time, high temperature, high SoC | | Lithium plating | LLI | Low temperature, fast charging, high SoC | | Mechanical degradation | LAMNE, LAMPE | High C-rates, deep cycling, volume changes | | Cathode degradation | LAMPE, Resistance increase | High voltage, high temperature | Understanding how specific mechanisms contribute to each mode helps in designing batteries and usage strategies that minimize degradation. ## Related Topics * [SEI Growth](/guide/batteries-101/sei)—the dominant calendar-aging mechanism, driving LLI and resistance increase * [Lithium Plating](/guide/batteries-101/lithium-plating)—LLI from fast charging and low temperatures, and a safety risk * [Mechanical Degradation](/guide/batteries-101/mechanical-degradation)—particle and binder cracking behind loss of active material * [State of Health](/guide/batteries-101/state-of-health)—how these degradation modes are measured and tracked over life * [Battery Capacity](/guide/batteries-101/battery-capacity)—what capacity fade means for usable energy # Electrode Essentials Source: https://docs.ionworks.com/guide/batteries-101/electrode-essentials What battery electrodes are made of, the difference between cathode and anode, and why positive/negative terminology can be confusing. Electrodes are where lithium is stored, and the difference in electrochemical potential between them is what enables a battery to deliver energy. This page breaks down what electrodes are and why the terminology used to describe them is not as straightforward as it seems at first glance. ## Electrode Structure Electrodes for lithium-ion batteries are porous thin layers made of materials that can store lithium through intercalation. When the battery is fully charged, lithium is predominantly stored in the negative electrode. During discharge, lithium ions move from the negative electrode to the positive electrode while electrons flow through the external circuit. Electrodes are not only made of the particles that can store lithium (called **active materials**) but also include: * **Binder**: Holds the electrode together * **Additives**: Improve performance, for example by increasing electronic conductivity ## The Two Electrodes Also known as the cathode. This electrode has a higher open-circuit potential, and lithium ions move towards it during discharge. The active material is typically a lithium-metal oxide (e.g., lithium cobalt oxide or lithium iron phosphate), though research continues into novel materials that can improve battery performance and lifetime. Also known as the anode. This electrode has a lower open-circuit potential, and lithium ions move away from it during discharge. The active material is usually graphite, sometimes with silicon or silicon oxide additions. Silicon offers higher intercalation capacity and potentially much higher energy densities, but its large expansion during lithiation (up to four times its initial volume) causes significant battery life issues. ## Why "Positive/Negative" Instead of "Cathode/Anode"? In Ionworks and PyBaMM, we use "positive electrode" and "negative electrode" instead of "cathode" and "anode," which are widely used in the literature. Here's why: The terms "anode" and "cathode" (coined by [William Whewell](https://en.wikipedia.org/wiki/William_Whewell) at the request of Michael Faraday) describe electrodes based on the direction of current flow: during discharge, the anode is where oxidation occurs and the cathode is where reduction occurs. In common usage, "anode" refers to the negative electrode and "cathode" to the positive electrode. This is correct **during discharge**. However, during charge, the roles reverse: the negative electrode becomes the cathode (reduction occurs) and the positive electrode becomes the anode (oxidation occurs). Despite the well-established convention, using anode/cathode is not correct from an electrochemical point of view. The positive/negative electrode nomenclature is more accurate: | Nomenclature | Definition | | ------------------ | ------------------------------------- | | Positive electrode | Has the higher open-circuit potential | | Negative electrode | Has the lower open-circuit potential | This leads us to another extremely important concept in lithium-ion batteries: the [open-circuit potential](/guide/batteries-101/open-circuit-voltage). ## Related Topics * [How Do Batteries Work?](/guide/batteries-101/how-do-batteries-work)—overview of lithium-ion battery operation * [Open-Circuit Voltage](/guide/batteries-101/open-circuit-voltage)—how electrode potentials determine battery voltage * [Reaction Kinetics](/guide/batteries-101/reaction-kinetics)—how quickly reactions occur at electrode surfaces * [Mechanical Degradation](/guide/batteries-101/mechanical-degradation)—how electrode materials degrade over time # How Do Batteries Work? Source: https://docs.ionworks.com/guide/batteries-101/how-do-batteries-work How lithium-ion batteries work: cathode, anode, separator, and electrolyte, and how ions move during charge and discharge. This guide covers the fundamentals of lithium-ion batteries. Whether you're new to battery science or just looking to brush up on the basics, this section will guide you through the inner workings of these devices that power our everyday lives—from phones and laptops to electric vehicles. ## What's Inside a Lithium-Ion Battery? If you were to open a lithium-ion battery (please don't try this at home under any circumstances), you'd find that it contains either a very long and thin sheet rolled up (in cylindrical or prismatic batteries) or several thin sheets stacked on top of one another (in pouch batteries). Looking closer, we'd notice that these sheets consist of several layers stacked together: Often referred to as the cathode. This is a porous layer typically made of lithium-metal oxide particles (e.g., lithium cobalt oxide or lithium iron phosphate) "glued" together by some additives. Often called the anode, it is similar to the positive electrode but typically made of graphite. A thin, porous membrane that keeps the positive and negative electrodes apart to prevent short circuits, while allowing ions to pass through. A liquid or gel that fills the pores within the electrodes and separator, facilitating the movement of lithium ions. It usually consists of a lithium salt dissolved in an organic solvent. Additionally, **current collectors** (thin metal foils—typically aluminum for the positive electrode and copper for the negative one) connect the electrodes to the external circuit. This combination of components, often referred to as a **cell**, forms the basic electrochemical unit capable of storing and delivering energy. ## How Does a Battery Work? At the heart of a battery's operation lies the movement of lithium ions and electrons between the electrodes. Here's a simplified explanation of the process: Lithium ions are stored (or "intercalated") within the layers of the negative electrode. This is the battery's high-energy state, ready to deliver power. When the battery is connected to a device, the external circuit is closed and electrochemical reactions occur at both electrodes. At the negative electrode, lithium deintercalates and releases electrons. These electrons flow through the external circuit, powering the device. Simultaneously, lithium ions travel through the electrolyte to the positive electrode, where they intercalate along with electrons arriving from the external circuit. By applying an external voltage to the battery, the process is reversed. Lithium ions move back to the negative electrode, restoring the battery to its high-energy state. This reversible movement of lithium ions and electrons is what makes lithium-ion batteries rechargeable. Of course, the process also involves additional phenomena such as gas formation, electrode swelling, and solid-electrolyte interphase (SEI) growth, which are covered in the [degradation](/guide/batteries-101/degradation) section. ## Related Topics * [Electrode Essentials](/guide/batteries-101/electrode-essentials)—dive deeper into electrode structure and terminology * [Open-Circuit Voltage](/guide/batteries-101/open-circuit-voltage)—understand the voltage that drives battery operation * [State of Charge](/guide/batteries-101/state-of-charge)—how we measure the charge remaining in a battery * [Battery Capacity](/guide/batteries-101/battery-capacity)—quantifying how much energy a battery can store # Internal Resistance Source: https://docs.ionworks.com/guide/batteries-101/internal-resistance How internal resistance causes voltage drop, power loss, and heat generation, and how it changes with SoC and aging. **Internal resistance** is a key property that determines how much of a battery's stored energy can actually be delivered as useful power. It causes voltage drops under load, limits power output, and generates heat during operation—making it critical for both performance and safety. ## What Is Internal Resistance? Internal resistance refers to the opposition a battery presents to the flow of current. Just like any electrical component, a battery isn't a perfect energy source—it has inherent resistance to: * Movement of ions in the electrolyte * Movement of electrons through the electrodes and current collectors This resistance causes: * Energy losses * Voltage drops under load * Heat generation during operation Internal resistance isn't a fixed value; it varies based on state of charge, temperature, battery chemistry, and aging. As a battery degrades over time, its internal resistance typically increases, leading to greater energy losses and reduced efficiency. ## Measurement Methods ### Direct Current (DC) Method This involves applying a current pulse to the battery and measuring the resulting voltage change. The resistance is calculated from Ohm's Law: $R_{DC} = \frac{\Delta V}{\Delta I}$ The measured value depends on when the voltage is sampled: * **Immediately after pulse** (within milliseconds): Captures primarily ohmic resistance from electrolyte, current collectors, and contacts * **After several seconds**: Includes charge-transfer resistance and some diffusion effects DC resistance measurements are simple and widely used for quality control and BMS algorithms, but the result depends on pulse duration, current magnitude, temperature, and state of charge. Always specify test conditions when reporting DC resistance values. ### Alternating Current (AC) Method A more sophisticated method, **Electrochemical Impedance Spectroscopy (EIS)**, applies an AC signal at different frequencies and measures the impedance response of the battery. | Advantage | Description | | --------------------------- | ----------------------------------------------------------------------- | | Separation of contributions | Distinguishes charge transfer resistance from diffusion-related effects | | Deeper insights | Provides information about battery aging and performance | EIS requires specialized equipment but provides more comprehensive information. ## Impact on Battery Performance Internal resistance plays a major role in battery performance through a series of interconnected effects: Higher resistance leads to greater voltage drops under load, reducing the power a battery can deliver. More energy is wasted as heat instead of powering the device. The generated heat raises the battery's temperature. Elevated temperature can accelerate degradation and, in extreme cases, lead to thermal runaway. For these reasons, internal resistance is one of the key metrics monitored by Battery Management Systems (BMS) to optimize performance and maintain safe operation. ## Connection to Thermal Behavior One of the most significant consequences of internal resistance is heat generation, making it impossible to assume a constant battery temperature. Predicting temperature dynamics is the main goal of [thermal models](/guide/batteries-101/thermal-modelling). ## Related Topics * [State of Charge](/guide/batteries-101/state-of-charge)—how resistance affects SoC estimation * [State of Health](/guide/batteries-101/state-of-health)—using resistance increase to track degradation * [Thermal Modeling](/guide/batteries-101/thermal-modelling)—how resistance causes heat generation * [Degradation Overview](/guide/batteries-101/degradation)—mechanisms that increase resistance over time * [Battery Management Systems](/guide/batteries-101/battery-management-systems)—how the BMS monitors resistance # Lithium Plating Source: https://docs.ionworks.com/guide/batteries-101/lithium-plating Why lithium plates as metal instead of intercalating, the conditions that cause it, and its impact on safety and life. **Lithium plating** occurs when metallic lithium deposits on the negative electrode surface instead of intercalating into it. This degradation mechanism reduces available lithium, increases resistance, and in severe cases forms dendrites that pose safety risks. Understanding the conditions that cause plating is crucial for fast-charging applications. ## What Is Lithium Plating? Lithium plating occurs when metallic lithium deposits on the surface of the negative electrode instead of intercalating into it. Unlike lithium ions stored within the electrode structure, plated lithium forms a separate layer that can significantly impact battery performance and safety. ## Conditions That Favor Lithium Plating At cold temperatures, lithium-ion diffusion slows down, making it harder for lithium ions to intercalate into the electrode. If charging continues at high rates, lithium can accumulate as a metallic layer. Fast charging pushes lithium ions into the anode rapidly. If the electrode cannot accommodate them quickly enough, plating occurs. When the negative electrode is nearly full, there are fewer available sites for lithium-ion storage, increasing the likelihood of plating. ## Why Lithium Plating Is Problematic Lithium plating contributes to battery degradation in multiple ways: | Effect | Consequence | | ----------------------------- | ----------------------------------------- | | Reduced lithium inventory | Less lithium available for charge storage | | Increased internal resistance | Higher energy losses, more heat | | Dendrite formation | Potential safety hazard | In severe cases, lithium plating forms **dendrites**—needle-like lithium structures that can pierce the separator and cause short circuits, potentially leading to **thermal runaway**. ## Modeling Lithium Plating Lithium plating is typically modeled alongside other electrochemical processes, much like SEI growth. A plating reaction term is added to the electrochemical equations to describe how much lithium deposits on the electrode surface, and an additional equation tracks how this affects electrode porosity. ### Modeling Approaches | Model Type | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **Irreversible plating** | Plated lithium is permanently lost, reducing the battery's lithium inventory over time | | **Reversible plating** | Under certain conditions, plated lithium can dissolve back into the electrolyte during discharge, partially mitigating its effects | | **Partially reversible plating** | A combination of both behaviors—some lithium is lost while some can re-enter the electrochemical cycle | These models assume lithium plating forms a uniform layer. To model dendritic growth, more complex 2D/3D modeling is required to fully capture their formation and impact on battery safety. ## Related Topics * [Degradation Overview](/guide/batteries-101/degradation)—how lithium plating contributes to LLI * [SEI Growth](/guide/batteries-101/sei)—a closely related anode degradation mechanism * [Mechanical Degradation](/guide/batteries-101/mechanical-degradation)—structural damage from repeated cycling * [Thermal Modeling](/guide/batteries-101/thermal-modelling)—how low temperatures promote plating * [Battery Management Systems](/guide/batteries-101/battery-management-systems)—how the BMS prevents plating conditions # Mechanical Degradation Source: https://docs.ionworks.com/guide/batteries-101/mechanical-degradation How particle expansion, cracking, and silicon swelling cause mechanical degradation and accelerate capacity fade. **Mechanical degradation** refers to the structural damage that occurs in batteries due to repeated expansion and contraction during cycling. While [SEI growth](/guide/batteries-101/sei) and [lithium plating](/guide/batteries-101/lithium-plating) are electrochemical processes, mechanical degradation causes physical damage—cracking particles, breaking electrical connections, and deforming cell structures—that leads to capacity loss and resistance increase. ## Volume Changes During Cycling During each charge and discharge cycle, lithium ions move in and out of the active material particles in each electrode. This insertion and extraction cause the particles to **expand and contract**, introducing mechanical stresses and deformations. ### Material-Dependent Expansion The extent of this expansion varies depending on the material: | Material | Volume Change | Notes | | -------- | ---------------- | ---------------------------------------- | | Graphite | Moderate (\~10%) | Standard anode material | | Silicon | Extreme (\~400%) | High capacity but significant challenges | Silicon is well known for its extreme expansion—swelling up to four times its original volume during lithiation. While silicon offers much higher capacity than conventional graphite anodes, this significant volume change presents a major challenge, leading to mechanical instability and faster degradation. Understanding the mechanics of batteries is not only important to extend battery life but also to enable new chemistries. ## Types of Mechanical Degradation The following are some specific examples of how mechanical effects degrade batteries: Repeated expansion and contraction creates stress within active material particles, leading to surface cracks that expose fresh material to the electrolyte, promoting additional SEI formation and increasing resistance. Stress can also crack the binder that holds particles together, reducing electrical conductivity and causing loss of active material as sections of the electrode become electrically isolated. In cylindrical cells, the wound electrode assembly (jellyroll) can deform or collapse due to internal pressure changes and electrode swelling, leading to uneven current distribution and localized degradation. Repeated volume changes during cycling can pump electrolyte in and out of electrode pores, leading to electrolyte redistribution, dry spots, and accelerated local degradation. ## Related Topics * [Degradation Overview](/guide/batteries-101/degradation)—how mechanical effects contribute to LAM * [SEI Growth](/guide/batteries-101/sei)—accelerated by particle cracking exposing fresh surfaces * [Lithium Plating](/guide/batteries-101/lithium-plating)—another major degradation mechanism * [Electrode Essentials](/guide/batteries-101/electrode-essentials)—electrode materials and their properties * [State of Health](/guide/batteries-101/state-of-health)—tracking capacity fade from active material loss # Open-Circuit Voltage and Potential Source: https://docs.ionworks.com/guide/batteries-101/open-circuit-voltage What open-circuit voltage (OCV) and open-circuit potential (OCP) mean, how they differ, and how they change with lithiation. Open-circuit voltage (OCV) is the voltage measured across a battery's terminals when no current is flowing. It reflects the electrochemical potential difference between the electrodes and is fundamental to understanding battery operation—from predicting [state of charge](/guide/batteries-101/state-of-charge) to modeling discharge behavior. ## Terminology: OCV vs OCP Let's distinguish between two related but different concepts: Defined for the **entire battery** as the electrical potential difference between the two electrodes when no current flows between them. This concept is widely used in electrochemical systems and electronic devices. Defined for **each electrode independently**. It corresponds to the OCV measured between that electrode and a reference electrode. The OCV of the battery equals the difference between the OCPs of the positive and negative electrodes. ## How OCPs Change with Lithiation For most electrode materials, the OCPs of each electrode (and thus the OCV of the battery) are not constant—they change as each electrode lithiates and delithiates: | Lithiation State | OCP | | ----------------- | ---------- | | Higher lithiation | Lower OCP | | Lower lithiation | Higher OCP | This is why the OCV of a battery is often used as a proxy for its **state of charge (SOC)**, which quantifies how much energy remains in the battery. As the battery discharges, its OCV decreases. While most electrode materials exhibit an OCP that changes with lithiation state, **some materials—like metallic lithium—have an OCP that remains nearly constant regardless of lithiation**. This unique property makes lithium metal useful as a reference electrode in laboratory measurements and contributes to its high energy density in battery applications. ## Role in Intercalation Reactions The OCPs play a critical role in the intercalation reactions at the surface of the active material: * When the electrode is **at its OCP**: Forward and backward intercalation reactions are in equilibrium—no net current at the interface * When electrode potential **rises above OCP**: Lithium deintercalates from the particles * When electrode potential **falls below OCP**: Lithium intercalates into the particles ## Connection to Charge/Discharge This ties back to the definition of positive and negative electrodes. When a battery is discharging: 1. The voltage drops below the battery's OCV 2. The potential of the positive electrode is lower than its OCP 3. The potential of the negative electrode is higher than its OCP 4. Result: Lithium deintercalates from the negative electrode and intercalates into the positive electrode There are many nuances glossed over here, such as the contributions of the electrolyte or the connection between OCPs and electrochemical potentials. ## Related Topics * [Electrode Essentials](/guide/batteries-101/electrode-essentials)—electrode structure and terminology * [State of Charge](/guide/batteries-101/state-of-charge)—how OCV relates to remaining charge * [Battery Capacity](/guide/batteries-101/battery-capacity)—measuring how much charge a battery can store * [Reaction Kinetics](/guide/batteries-101/reaction-kinetics)—how OCP drives intercalation reactions # Reaction Kinetics Source: https://docs.ionworks.com/guide/batteries-101/reaction-kinetics How Butler-Volmer, Tafel, and Marcus-Hush-Chidsey equations describe electrochemical reaction rates in batteries. **Reaction kinetics** describe how quickly lithium ions can intercalate or deintercalate at the electrode-electrolyte interface. While [open-circuit potentials](/guide/batteries-101/open-circuit-voltage) determine the direction of reactions, kinetics determine the rate—making them fundamental to understanding battery power output, efficiency, and the physics behind rate-dependent [capacity](/guide/batteries-101/battery-capacity) loss. ## What Are Reaction Kinetics? Reaction kinetics describe the rates of electrochemical reactions that occur at the interface between the electrodes and the electrolyte. These reactions involve: * Transfer of lithium ions across the interface * Simultaneous transfer of electrons through the external circuit As a result, they are strongly influenced by the concentrations and potentials in both the electrodes and the electrolyte. The rate at which these reactions occur directly impacts a battery's: * Power output * Efficiency * Charge/discharge behavior ## Overpotential: The Driving Force When the battery is operating, the electrodes are no longer at their OCPs, and there is a driving force for the reactions. This driving force is defined as the **reaction overpotential** ($\eta$): $\eta = \phi_s - \phi_e - U$ where $\phi_s$ is the electrode potential, $\phi_e$ is the electrolyte potential, and $U$ is the OCP. ## The Butler-Volmer Equation The most commonly used equation to describe reaction kinetics in batteries is the **Butler-Volmer equation**. It provides a detailed, non-linear relationship between the overpotential and the current density (reaction rate) at the electrode surface: $i = i_0 \left[ \exp\left(\frac{\alpha_a F \eta}{RT}\right) - \exp\left(-\frac{\alpha_c F \eta}{RT}\right) \right]$ where: * $i$ is the net current density * $i_0$ is the exchange current density (reaction rate at equilibrium) * $\eta$ is the overpotential * $\alpha_a$ and $\alpha_c$ are the anodic and cathodic charge transfer coefficients * $F$ is the Faraday constant * $R$ is the universal gas constant * $T$ is the temperature If $\alpha_a = \alpha_c = 0.5$, the two exponentials can be combined into a hyperbolic sine function, which is commonly found in battery models. ### Behavior at Different Overpotentials | Overpotential | Behavior | | ------------- | ------------------------------------------------------------------------------------------------- | | Small | Both terms contribute, leading to a symmetrical response around equilibrium (reversible reaction) | | Large | One term dominates (depending on anodic or cathodic), simplifying to the Tafel equation | ## The Tafel Equation At higher overpotentials, the Butler-Volmer equation simplifies to the **Tafel equation**: $i = i_0 \exp\left(\frac{\alpha_a F \eta}{RT}\right)$ The Tafel equation is most often used to model **irreversible reactions**, where only one direction of the reaction (e.g., growth of a degradation product) is significant. In this regime, when the overpotential is negative, the current decreases exponentially and becomes negligible, reflecting that the reaction essentially only proceeds in one direction. ## Advanced Kinetics: Marcus-Hush-Chidsey While Butler-Volmer kinetics work well in many situations, they are based on phenomenological assumptions that break down under certain conditions. **Marcus-Hush-Chidsey (MHC)** theory provides a more fundamental description of electron transfer kinetics rooted in quantum mechanics. ### Why Go Beyond Butler-Volmer? Butler-Volmer assumes that current increases exponentially without limit as overpotential increases. However, experiments show that at very high overpotentials, the reaction rate can saturate or even decrease—behavior that Butler-Volmer cannot capture. | Kinetic Model | Physical Basis | High Overpotential Behavior | | ------------------- | ------------------------------------ | ---------------------------- | | Butler-Volmer | Phenomenological | Exponential growth | | Marcus-Hush-Chidsey | Quantum mechanical electron transfer | Saturation (inverted region) | ### The Marcus Theory Foundation Marcus theory describes electron transfer as requiring reorganization of the solvent and molecular structure. The key parameter is the **reorganization energy** ($\lambda$), which represents the energy needed to rearrange the environment for electron transfer to occur. The rate depends on the alignment between the electronic energy levels of the electrode and the redox species. At moderate overpotentials, increasing the driving force speeds up the reaction. But beyond a critical point (the "inverted region"), further increases in overpotential can actually slow the reaction. ### Marcus-Hush-Chidsey for Electrodes MHC theory extends Marcus theory to metal electrodes by integrating over the continuum of electronic states in the metal. The resulting current density is: $i = i_0 \int_{-\infty}^{\infty} \frac{\exp\left[-\frac{(\lambda - x)^2}{4\lambda k_B T}\right]}{1 + \exp(x - \eta^*)} \, dx$ where $\eta^* = F\eta / k_B T$ is the dimensionless overpotential and $\lambda$ is the reorganization energy. Unlike Butler-Volmer, MHC kinetics are inherently asymmetric and predict current saturation at high overpotentials—matching experimental observations in many systems. ### When to Use MHC Kinetics MHC kinetics are particularly relevant for: * **Fast charging** conditions where high overpotentials are reached * **Certain electrode materials** (e.g., some intercalation compounds) where asymmetric kinetics are observed * **Fundamental studies** requiring accurate kinetic descriptions For most practical battery modeling, Butler-Volmer remains the standard choice due to its simplicity and adequate accuracy. MHC kinetics are typically reserved for detailed mechanistic studies or when experimental data shows clear deviations from Butler-Volmer predictions. ## Application in Physics-Based Models In physics-based models, reaction kinetics play a critical role in describing the interfacial behavior between the electrodes and the electrolyte. These kinetics strongly couple the equations for concentrations and potentials in the electrodes and electrolyte. | Equation | Application | | ------------- | --------------------------------------------------------------------------- | | Butler-Volmer | Reversible reaction of lithium intercalation into active material particles | | Tafel | Irreversible degradation reactions, such as SEI growth | Reaction kinetics are the bridge between the thermodynamics of equilibrium (OCPs) and the dynamic behavior of a battery under use. They are a key factor in the trade-off between current and capacity, which is explored in [Battery Capacity](/guide/batteries-101/battery-capacity). ## Related Topics * [Open-Circuit Voltage](/guide/batteries-101/open-circuit-voltage)—the equilibrium potentials that drive reactions * [Battery Capacity](/guide/batteries-101/battery-capacity)—how kinetics affect usable capacity at different C-rates * [Internal Resistance](/guide/batteries-101/internal-resistance)—the macroscopic effect of reaction overpotentials * [SEI Growth](/guide/batteries-101/sei)—a degradation reaction described by Tafel kinetics # SEI Growth Source: https://docs.ionworks.com/guide/batteries-101/sei What the solid electrolyte interphase (SEI) is, why it forms during formation cycles, and how its growth drives battery aging. The **Solid Electrolyte Interphase (SEI)** is a thin layer that forms on the negative electrode surface in lithium-ion batteries. While essential for stable operation, the SEI continues to grow throughout the battery's lifetime, consuming lithium and increasing resistance. SEI growth is one of the most fundamental—yet still not fully understood—processes affecting battery lifespan. ## What Is the SEI? The **Solid Electrolyte Interphase (SEI)** is a thin layer that forms on the surface of the negative electrode (anode) in lithium-ion batteries. A similar process occurs on the positive electrode, but it has received much less attention. The SEI results from electrolyte decomposition and is crucial during the first few charging cycles—a stage known as **formation**—but it continues evolving throughout the battery's lifetime. ## Why the SEI Matters The SEI acts as a protective barrier, preventing further electrolyte breakdown while allowing lithium ions to pass through. ### Benefits of a Well-Formed SEI | Benefit | Description | | ---------------------------------- | --------------------------------------------- | | Prevents electrolyte decomposition | Reduces unwanted side reactions | | Regulates lithium-ion transport | Ensures efficient charge and discharge cycles | | Stabilizes the interface | Enables long-term battery operation | ### The Problem: Continuous Growth The SEI is not a static layer. Over time, it thickens due to continuous side reactions, trapping lithium ions and increasing resistance. This leads to **capacity fade** and **power loss**. ## Modeling SEI Growth Due to its complexity, SEI growth is not fully understood, and many different modeling approaches attempt to capture different aspects of the process. Typically, physics-based SEI models are defined by coupling additional equations on top of electrochemical models. ### Reaction Equations One set of equations describes the electrochemical reactions that create the SEI. Despite significant research efforts, the precise physics behind SEI formation remain an open question. Many models assume a particular limiting factor for SEI growth—such as solvent diffusion or electron tunneling—so that other effects can be disregarded. ### Porosity Changes SEI growth also affects the porosity of the negative electrode, as it fills up void spaces originally meant for electrolyte penetration. Another equation is needed to track porosity changes over time. ## Related Topics * [Degradation Overview](/guide/batteries-101/degradation)—how SEI growth fits into the broader picture of battery aging * [State of Health](/guide/batteries-101/state-of-health)—tracking capacity fade from SEI growth * [Lithium Plating](/guide/batteries-101/lithium-plating)—another anode degradation mechanism intertwined with SEI * [Mechanical Degradation](/guide/batteries-101/mechanical-degradation)—particle cracking that exposes fresh SEI formation sites * [Reaction Kinetics](/guide/batteries-101/reaction-kinetics)—the Tafel equation used to model SEI growth # State of Charge Source: https://docs.ionworks.com/guide/batteries-101/state-of-charge What state of charge (SoC) means and how to estimate it using coulomb counting and voltage-based methods. **State of Charge (SoC)** is a dynamic measure of how much charge remains in a battery at any given moment, expressed as a percentage of its total [capacity](/guide/batteries-101/battery-capacity). It's analogous to a fuel gauge—telling you how much capacity is available before the battery needs recharging. SoC is an essential parameter for: * Estimating runtime (e.g. smartphone battery life, electric vehicle range) * Protecting batteries from overcharging or over-discharging ## Definition State of Charge is often expressed as a percentage, with 100% representing a fully charged battery and 0% representing an empty battery. Sometimes, it is expressed as a fraction of the battery's total capacity, with 1 representing a fully charged battery and 0 representing an empty battery. While this definition seems intuitive, accurately determining SoC can be complex. ## Estimation Methods ### Coulomb Counting **Coulomb counting** (or current integration) is one of the most widely used methods for SoC estimation. This approach calculates SoC by measuring the current flowing into or out of the battery over time. $\text{SoC}(t) = \text{SoC}_0 + \frac{1}{Q} \int_0^t I(\tau) \, d\tau$ where $Q$ is the total capacity and $I$ is the current. While precise over short periods, Coulomb counting is prone to cumulative errors over time due to measurement inaccuracies and side reactions (e.g., self-discharge). ### Open-Circuit Voltage Method [Open-circuit voltage (OCV)](/guide/batteries-101/open-circuit-voltage) is another commonly used approach to estimating SoC. The OCV of a battery correlates directly with its SoC. Many manufacturers provide **OCV-SoC curves** that map the relationship between voltage and SoC for a specific battery chemistry. By measuring the battery's OCV after it has rested (with no current flowing), the SoC can be estimated accurately. ### Voltage Under Load In some cases, the actual voltage of a battery under load is used to estimate SoC. However, this requires caution: | Measurement | Relationship to SoC | | ------------------ | ------------------------------------------------------------ | | OCV (at rest) | Well-defined relationship | | Voltage under load | Affected by transient effects (Ohmic losses, overpotentials) | After a discharge, voltage often rises slightly as lithium concentrations equilibrate in the electrodes and electrolyte. If this operational voltage is used naively to estimate SoC, it may appear that SoC increases after discharge stops—an incorrect result since no additional charge has entered the battery. ### Kalman Filtering **Kalman filtering** is an advanced estimation technique that combines multiple information sources to produce more accurate SoC estimates than any single method alone. It's widely used in modern battery management systems. The key insight is that each estimation method has different strengths: | Method | Strength | Weakness | | ---------------- | --------------------------- | --------------------- | | Coulomb counting | Good short-term tracking | Drifts over time | | OCV lookup | Accurate absolute reference | Requires rest periods | A Kalman filter fuses these approaches by: 1. **Predicting** the next SoC using a model (e.g., Coulomb counting) 2. **Updating** the prediction when a measurement becomes available (e.g., voltage) 3. **Weighting** each source based on its uncertainty The filter maintains an estimate of both the SoC and its uncertainty. When measurements are noisy or the model is uncertain, the filter automatically adjusts how much to trust each source. The Extended Kalman Filter (EKF) and Unscented Kalman Filter (UKF) are variants that handle the nonlinear relationship between SoC and voltage, making them particularly well-suited for battery applications. The mathematical formulation involves a state-space model: $x_{k+1} = f(x_k, u_k) + w_k$ $y_k = h(x_k) + v_k$ where $x_k$ is the state (SoC), $u_k$ is the input (current), $y_k$ is the measurement (voltage), and $w_k$, $v_k$ represent process and measurement noise. Kalman filters can estimate not just SoC but also other hidden states like internal resistance and capacity, enabling joint SoC-SoH estimation. ## Impact of Battery Aging As a battery ages, its total capacity decreases. This can cause a mismatch between estimated and actual SoC if capacity fade is not accounted for. Advanced battery management systems (BMS) dynamically adjust the total capacity over time, improving SoC accuracy throughout the battery's lifespan. ## State of Power (SoP) While SoC tells us how much charge remains, **State of Power (SoP)** tells us the maximum power the battery can deliver or accept at any given moment. SoP is calculated in real-time and depends on: | Factor | Effect on SoP | | --------------- | ------------------------------------------------------------------------------------------------------------- | | **SoC** | At very high SoC, charge power is limited to prevent overvoltage; at very low SoC, discharge power is limited | | **Temperature** | Cold batteries have reduced power capability due to increased resistance | | **SoH** | Aged batteries with higher resistance have lower power limits | For example, if a battery is at 95% SoC, the SoP for charging will be very low to prevent overvoltage, even though the battery isn't technically "full." SoP is critical for applications like electric vehicles, where the [BMS](/guide/batteries-101/battery-management-systems) must communicate power limits to the motor controller in real-time. ## State of Energy (SoE) **State of Energy (SoE)** estimates the remaining usable energy in the battery, accounting for: * The varying voltage during discharge * Efficiency losses at different power levels * Temperature effects on available energy While SoC is based on charge (Ah), SoE is based on energy (Wh): $\text{SoE} = \frac{\text{Remaining energy (Wh)}}{\text{Total energy capacity (Wh)}} \times 100\%$ SoE provides a more accurate estimate of remaining range or runtime than SoC alone, since: * A battery at 50% SoC doesn't necessarily have 50% of its energy remaining * Higher discharge rates reduce the usable energy due to voltage drop and thermal losses ## Related Concepts State of Charge, together with SoP and SoE, forms the foundation of battery state estimation in modern [battery management systems](/guide/batteries-101/battery-management-systems). Another key factor is [internal resistance](/guide/batteries-101/internal-resistance), which plays a critical role in efficiency, heat generation, and power output. # State of Health Source: https://docs.ionworks.com/guide/batteries-101/state-of-health What state of health (SoH) measures, how cycling and calendar aging cause degradation, and how SoH is calculated. Batteries don't last forever. Over time, they gradually lose capacity, efficiency, and power output—a process known as **battery degradation** or aging. This decline affects everything from electric vehicle range to smartphone battery life. It's even the primary reason why satellites go out of service! **State of Health (SoH)** quantifies how much a battery has degraded compared to its original condition. ## What Causes Battery Degradation? Battery degradation occurs due to a combination of physical and chemical changes within the cell. Contributing factors include: * Repeated charge and discharge cycles * Extreme operating temperatures * Passage of time (even when unused) ### Types of Aging | Type | Cause | | ------------------ | -------------------------------------------------------------------------- | | **Cycling aging** | Degradation caused by battery operation—charging and discharging over time | | **Calendar aging** | Degradation due to the passage of time, even if the battery sits unused | Key degradation mechanisms include the growth of the [solid electrolyte interphase (SEI)](/guide/batteries-101/sei), [lithium plating](/guide/batteries-101/lithium-plating), and loss of active material. These are covered in detail in the [degradation overview](/guide/batteries-101/degradation). ## Defining State of Health (SoH) **State of Health (SoH)** is a metric that quantifies how much a battery has degraded compared to its original state. It is typically expressed as a percentage: * **100%**: Brand-new battery * Lower values indicate reduced capacity or performance ## Methods for Measuring SoH ### Capacity-Based SoH The most common definition measures how much of the original capacity remains. $\text{SoH} = \frac{Q_{\text{current}}}{Q_{\text{original}}} \times 100\%$ For example, if a battery was originally rated for 100 Ah and now delivers only 80 Ah, its SoH is 80%. This is measured under specific discharge conditions, and different conditions may yield different SoH measurements. ### Resistance-Based SoH Since [internal resistance](/guide/batteries-101/internal-resistance) increases with aging, some methods estimate SoH by tracking the rise in resistance over time. Higher resistance leads to: * Greater energy losses * Reduced power output Typically, resistance-based SoH is used in conjunction with capacity-based SoH to provide a more complete picture of battery degradation, never estimating SoH from resistance alone. ### Model-Based SoH Estimation Advanced battery management systems (BMS) use a combination of: * Real-time data * Historical usage patterns * Electrochemical models to estimate SoH dynamically. ## Why SoH Matters Monitoring SoH is crucial for ensuring reliable battery operation: | Application | Impact of Low SoH | | -------------------- | ----------------------------------------------------------------------- | | Consumer electronics | Shorter battery life | | Electric vehicles | Reduced driving range and charging speed | | Grid storage | Lower energy availability and system efficiency | | Temperature control | Degraded cells generate more heat, risking imbalance or thermal runaway | Knowing when a battery has reached an unacceptable SoH threshold helps determine when it should be: * Replaced * Repurposed (e.g., for second-life applications) * Recycled ## Understanding Degradation Mechanisms Understanding battery degradation is key to extending battery lifespan, improving reliability, and designing better energy storage systems. The [degradation overview](/guide/batteries-101/degradation) takes a closer look at the mechanisms behind battery aging. # Thermal Modeling Source: https://docs.ionworks.com/guide/batteries-101/thermal-modelling Heat sources in batteries (ohmic, reaction, entropic), lumped vs distributed thermal models, and thermal runaway risk. When a battery operates, not all energy goes into powering devices—some is inevitably lost as heat due to [internal resistance](/guide/batteries-101/internal-resistance) and electrochemical processes. Managing this heat is essential for performance, lifespan, and safety, especially in large-format cells used in electric vehicles or grid storage. ## Heat Generation in Batteries Every battery generates heat during operation. The main sources include: | Source | Description | | ----------------------- | -------------------------------------------- | | Ohmic losses | Resistive heating from current flow | | Reaction overpotentials | Energy lost at electrode interfaces | | Concentration gradients | Entropic effects from lithium redistribution | In small cells or at low currents, the generated heat may dissipate naturally. However, in high-power or large-capacity applications, heat can accumulate, leading to temperature rises that affect battery performance and accelerate degradation. In extreme cases, excessive heat can trigger **thermal runaway**—a dangerous, self-reinforcing cycle of overheating. ## Thermal Model Types Thermal models are coupled with electrochemical models (such as the Doyle-Fuller-Newman model): the electrochemical processes dictate how much heat is generated, while the battery temperature affects transport properties inside the cell. ### Lumped Thermal Models These treat the entire battery as having a **uniform temperature**. **Advantages:** * Simpler and computationally efficient * Suitable for real-time battery management systems (BMS) **Best for:** * Systems where temperature gradients are minimal * Applications requiring fast calculations ### Spatially Distributed Models These account for **temperature variations within the battery**. Depending on the desired level of resolution, these models can capture: * Temperature differences across the current collector or cell thickness * Detailed variations within each battery layer **Best for:** * Large-format cells * High-power applications where internal temperature gradients significantly impact performance and safety ## Choosing the Right Model | Model Type | Complexity | Accuracy | Use Case | | --------------------- | ---------- | -------- | --------------------- | | Lumped | Low | Moderate | Real-time BMS control | | Spatially distributed | High | High | Design and analysis | In practice, many battery management systems use simplified models for real-time control, while more detailed models are employed for design and analysis. ## Impact on Battery Aging Temperature control isn't just about efficiency—it directly influences battery aging. Elevated temperatures accelerate chemical degradation processes, leading to: * Capacity fade * Increased internal resistance This brings us to the topic of [State of Health (SoH)](/guide/batteries-101/state-of-health) and battery degradation. ## Related Topics * [Internal Resistance](/guide/batteries-101/internal-resistance)—the primary source of heat generation * [State of Health](/guide/batteries-101/state-of-health)—how temperature affects battery aging * [Degradation Overview](/guide/batteries-101/degradation)—mechanisms accelerated by high temperature * [Lithium Plating](/guide/batteries-101/lithium-plating)—a degradation mechanism affected by low temperature * [Battery Packs](/guide/batteries-101/battery-packs)—thermal management at the pack level # Geometry & Capacity Source: https://docs.ionworks.com/guide/calculations/geometry-capacity Compute electrode capacity, cyclable lithium, active material loading, and electrode mass from cell geometry and material properties. Geometric and capacity parameters define the physical structure of a battery cell and its energy storage capability. These calculations are foundational for building accurate battery models. ## The Capacity Equation Electrode capacity is determined by geometry and material properties: $$ Q = c_{\max} \cdot \varepsilon_{\text{AM}} \cdot L \cdot A \cdot n_{\text{elec}} \cdot (\theta_{\max} - \theta_{\min}) \cdot \frac{F}{3600} $$ where: * $Q$ is the electrode capacity \[A·h] * $c_{\max}$ is the maximum lithium concentration \[mol/m³] * $\varepsilon_{\text{AM}}$ is the active material volume fraction * $L$ is the electrode thickness \[m] * $A$ is the electrode area \[m²] * $n_{\text{elec}}$ is the number of electrodes connected in parallel (defaults to 1) * $\theta_{\max} - \theta_{\min}$ is the usable stoichiometry range * $F = 96485$ C/mol is Faraday's constant This equation relates six parameters (seven when $n_{\text{elec}}$ is specified)—if you know all but one, you can solve for it. For example, when capacity is known, the `ElectrodeCapacity` calculation can solve for maximum concentration; supply any five of the six parameters and it returns the missing one. To configure these calculations in a pipeline, see [Pipelines → Calculations → Geometry & Capacity](/build/parameterize/calculations/geometry-capacity). ## Cell Geometry ### Geometry Hierarchy Battery cells are organized hierarchically: ``` Cell ├── Electrode Stack │ ├── Current Collector (negative) │ ├── Electrode (negative) │ ├── Separator │ ├── Electrode (positive) │ └── Current Collector (positive) └── Housing / Packaging ``` ### Key Parameters | Parameter | Symbol | Typical Range | Impact | | --------------------------------------------------------- | ----------------- | ------------- | ------------------------- | | Electrode area | $A$ | 0.01-1 m² | Capacity, current density | | Electrode thickness | $L$ | 50-150 µm | Capacity, rate capability | | Number of electrodes connected in parallel to make a cell | $n_{\text{elec}}$ | 1-100+ | Total capacity | | Separator thickness | — | 15-25 µm | Ionic resistance | | Current collector | — | 10-20 µm | Electrical resistance | For pouch and prismatic cells, electrode area is the planar area times the number of layers. For cylindrical cells, it's the unrolled electrode area. ## Cyclable Lithium Cyclable lithium is the total lithium that shuttles between electrodes during cycling. It sets the upper limit on cell capacity. $$ Q_{\text{Li}} = \theta_n \cdot Q_n + \theta_p \cdot Q_p $$ where stoichiometries $\theta_n, \theta_p$ are evaluated at a reference state (typically 100% SOC). ### Why It Matters * **Cell capacity**: Cannot exceed cyclable lithium, regardless of electrode capacities * **Degradation tracking**: Loss of cyclable lithium indicates SEI growth, plating, or particle cracking * **Electrode balancing**: Determines which electrode limits cell capacity ### N/P Ratio and Electrode Balancing The negative-to-positive capacity ratio (N/P ratio) affects how electrodes are utilized: $$ \text{N/P ratio} = \frac{Q_n}{Q_p} $$ | Condition | Behavior | | ----------------- | ------------------------------------------------------- | | N/P > 1 (typical) | Positive electrode limits capacity; negative has excess | | N/P \< 1 | Negative electrode limits; risk of lithium plating | | Lithium-limited | Neither electrode reaches stoichiometry limits | Cells are typically designed with N/P > 1 to prevent lithium plating at the negative electrode during charge. ## Mass Calculations Mass is needed for gravimetric energy density and thermal modeling. ### Component Mass Each component's mass is calculated from: $$ m = \rho \cdot A \cdot L \cdot (1 - \varepsilon) $$ where $\rho$ is density, $A$ is area, $L$ is thickness, and $\varepsilon$ is porosity. ### Energy Density Gravimetric and volumetric energy densities are key cell-level metrics: $$ E_{\text{grav}} = \frac{Q \cdot V_{\text{avg}}}{m_{\text{cell}}}, \quad E_{\text{vol}} = \frac{Q \cdot V_{\text{avg}}}{V_{\text{cell}}} $$ ## Microstructure Microstructure parameters describe the porous electrode architecture: ### Porosity The void fraction of the electrode: $$ \varepsilon = 1 - \varepsilon_{\text{AM}} - \varepsilon_{\text{binder}} - \varepsilon_{\text{carbon}} $$ Higher porosity improves electrolyte transport but reduces energy density. ### Tortuosity Describes how much longer the effective transport path is compared to the straight-line distance: $$ D_{\text{eff}} = \frac{D \cdot \varepsilon}{\tau} $$ Common correlations relate tortuosity to porosity: * **Bruggeman**: $\tau = \varepsilon^{-0.5}$ * **Measured**: From electrochemical impedance or other techniques ### Active Material Volume Fraction The fraction of electrode volume occupied by active material: $$ \varepsilon_{\text{AM}} = \frac{V_{\text{AM}}}{V_{\text{electrode}}} $$ This is a key fitting parameter affecting both capacity and transport. ## Practical Workflow Set electrode area, thicknesses, and number of layers Set active material volume fractions and calculate porosity Use `ElectrodeCapacity` for each electrode Use `CyclableLithium` to find the limiting capacity Use `CellMass` to roll the component contributions up to a cell-level mass ## Common Calculations | Calculation | Purpose | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ElectrodeCapacity` | Solve capacity equation for any unknown | | `CyclableLithium` | Calculate total shuttling lithium | | `CellMass` | Cell mass summed from component densities, thicknesses, and porosities | | `ElectrodeVolumeFractionFromLoading` / `ElectrodeVolumeFractionFromPorosity` | Active material volume fraction from loading or porosity | | `PorosityFromElectrodeVolumeFraction` | Porosity from active material volume fraction | | `SurfaceAreaToVolumeRatio` | Electrochemically active surface area per unit electrode volume — the bridge from geometric electrode dimensions to reactive area used by the porous-electrode equations | # Piecewise Interpolants Source: https://docs.ionworks.com/guide/calculations/piecewise Build smooth, differentiable piecewise interpolants for OCP, diffusivity, and SOC- or temperature-dependent battery parameters. Many battery parameters depend on state of charge, temperature, or both. Piecewise interpolation enables the creation of smooth parameter functions suitable for physics-based models. This guide covers both general piecewise interpolants and specialized OCP interpolants. ## Overview Piecewise interpolation addresses several needs: * **SOC-dependent parameters**: Diffusivity, exchange current density, and other transport properties often vary significantly with lithiation state * **Temperature dependence**: Non-Arrhenius behavior requires more flexible functional forms * **OCP functions**: Open-circuit potential must be defined across the full stoichiometry range for simulation The implementation uses smooth heaviside functions to create continuous, differentiable interpolants that work well with differential equation solvers. ## Mathematical Formulation ### Smooth Heaviside Function Traditional piecewise functions use discontinuous step functions, which cause convergence issues in ODE/DAE solvers. Instead, we use a smooth approximation: $$ H_{\text{smooth}}(x; \theta, \epsilon) = \frac{1}{2}\left(1 + \tanh\left(\frac{x - \theta}{2\epsilon}\right)\right) $$ where: * $x$ is the input variable (e.g., SOC) * $\theta$ is the threshold value * $\epsilon$ is the smoothing parameter (larger = smoother transition) The smooth heaviside is infinitely differentiable ($C^\infty$) and transitions from \~0 for $x \ll \theta$ to \~1 for $x \gg \theta$. ### 1D Piecewise Linear Interpolation For a parameter $p$ varying with $x$, given breakpoints $\{x_0, x_1, \ldots, x_{N-1}\}$ with values $\{p_0, p_1, \ldots, p_{N-1}\}$: $$ p(x) = p_0 H_0^-(x) + \sum_{i=0}^{N-2} p_i^{\text{seg}}(x) H_i^{\text{seg}}(x) + p_{N-1} H_{N-1}^+(x) $$ Each linear segment interpolates between adjacent knots: $$ p_i^{\text{seg}}(x) = p_i + (p_{i+1} - p_i) \frac{x - x_i}{x_{i+1} - x_i} $$ ## Piecewise Parameter Functions ### 1D Interpolation For parameters that vary with a single variable (typically SOC), a `PiecewiseInterpolation1D` direct entry takes a base parameter name, a list of breakpoint values along the independent variable, and a smoothing parameter. The interpolant reads one parameter per breakpoint (e.g. `"Negative particle diffusivity at SOC 0.3 [m2.s-1]"`) and returns a smooth function of the independent variable. ### 2D Interpolation For parameters varying with two variables (e.g., SOC and temperature), a `PiecewiseInterpolation2D` direct entry takes breakpoints along both axes and separate smoothing parameters for each. Use different smoothing parameters for dimensions with different scales (e.g., SOC in \[0, 1] vs Temperature in \[273, 323] K). To configure these interpolants in a pipeline, see [Pipelines → Calculations → Piecewise](/build/parameterize/calculations/piecewise). ### Formulations: Knots vs Slopes Two equivalent parameterizations are available: | Formulation | Parameters | Best for | | ----------- | ------------------------------------------ | --------------------------------- | | **Knots** | Values at each breakpoint | Direct measurements | | **Slopes** | Initial value + slopes between breakpoints | Optimization (smoother landscape) | Both have the same degrees of freedom. Slopes can be converted to knots using `SlopesToKnots`. ## OCP Interpolants Open-circuit potential (OCP) interpolants are specialized functions $U(\theta)$ that return equilibrium voltage for a given stoichiometry. ### From Experimental Data An `OCPDataInterpolant` calculation builds a smooth interpolant from half-cell OCP measurements. Data should be collected at low C-rates (C/20 or slower) to approximate equilibrium. ### From MSMR Parameters An `OCPMSMRInterpolant` calculation evaluates the MSMR model over a voltage range to create an interpolant. This provides thermodynamic consistency and reliable extrapolation. ### Blended MSMR / Experimental OCP The recommended approach combines experimental data with MSMR extrapolation via `OCPDataInterpolantMSMRExtrapolation`. The blending function smoothly transitions between data and MSMR: $$ U(x) = w(x) \cdot U_{\text{data}}(x) + (1 - w(x)) \cdot U_{\text{MSMR}}(x) $$ where $w(x)$ is a bump function that equals \~1 inside the data range and \~0 outside. ### The Inaccessible Lithium Problem Experimental OCP data never covers the full 0-1 stoichiometry range due to: * Electrolyte decomposition at low voltages * Structural instability at high voltages Without physics-based extrapolation, simulations that access stoichiometries outside the data range produce unreliable results. Always use MSMR blending if your simulation might access stoichiometries outside your experimental range. The MSMR model provides physically reasonable behavior at the extremes. ## Numerical Considerations ### Smoothing Parameter Selection | Value | Effect | | ----------- | ------------------------------------------------------------------ | | Too small | Approaches discontinuous, may cause solver issues | | Too large | Excessive smoothing, reduces accuracy | | Recommended | $\epsilon \approx 10^{-4}$ to $10^{-3} \times$ (range of variable) | ### Extrapolation Behavior Both piecewise and OCP interpolants use **constant extrapolation** outside their defined ranges. This prevents unbounded values and is physically reasonable for many battery parameters. ## Common Use Cases Capture phase transitions or concentration effects on transport Define equilibrium voltage for electrochemical models Non-Arrhenius temperature dependence Parameters depending on multiple variables simultaneously ## Best Practices 1. **Breakpoint placement**: Place more breakpoints where the parameter changes rapidly 2. **Smoothing selection**: Start with defaults; increase if solver has trouble 3. **Visualization**: Always plot interpolants across the full range to check for artifacts 4. **MSMR for OCP**: Use blended interpolants for robust extrapolation # Thermal Calculations Source: https://docs.ionworks.com/guide/calculations/thermal Model Arrhenius temperature dependence, heat capacity, and heat generation for lithium-ion battery thermal simulations. Temperature affects nearly every aspect of battery behavior: reaction kinetics, transport properties, degradation rates, and safety. This guide covers both thermal property calculations and temperature-dependent parameter modeling. ## Why Thermal Modeling Matters Temperature influences batteries through multiple mechanisms: * **Kinetics**: Reaction rates increase exponentially with temperature (Arrhenius) * **Transport**: Diffusivity and ionic conductivity are strongly temperature-dependent * **Degradation**: Higher temperatures accelerate aging mechanisms * **Safety**: Thermal runaway is the primary safety concern for lithium-ion batteries Understanding and modeling these effects is essential for accurate simulations and safe designs. ## Arrhenius Temperature Dependence The Arrhenius equation describes how rate-limited parameters vary with temperature: $$ k(T) = A \exp\left(-\frac{E_a}{RT}\right) $$ where: * $A$ is the pre-exponential factor * $E_a$ is the activation energy \[J/mol] * $R = 8.314$ J/(mol·K) is the gas constant * $T$ is absolute temperature \[K] ### Physical Interpretation The activation energy $E_a$ represents the energy barrier for the process: * **Diffusion**: Energy barrier for ions hopping between sites * **Reaction kinetics**: Energy barrier for electrochemical reactions * **Conductivity**: Energy for ion transport through the material Higher activation energies mean stronger temperature sensitivity. ### Reference Temperature Formulation A more practical form uses a reference temperature: $$ k(T) = k_{\text{ref}} \exp\left[-\frac{E_a}{R}\left(\frac{1}{T} - \frac{1}{T_{\text{ref}}}\right)\right] $$ where $k_{\text{ref}} = k(T_{\text{ref}})$ is the value at the reference temperature (typically 298.15 K). Using a reference temperature makes parameters more intuitive—$k_{\text{ref}}$ is the value at room temperature rather than an abstract pre-exponential factor. ### Fitting Arrhenius Parameters Taking the logarithm linearizes the relationship: $$ \ln k = \ln A - \frac{E_a}{R} \cdot \frac{1}{T} $$ Plotting $\ln k$ vs $1/T$ gives a straight line with slope $-E_a/R$ — the basis of the `ArrheniusLogLinear` calculation, which fits $(k_{\text{ref}}, E_a)$ from a table of $(T, k)$ measurements. To run an Arrhenius fit or specific-heat calculation, see [Pipelines → Calculations → Thermal](/build/parameterize/calculations/thermal). ### Typical Activation Energies | Parameter | Typical $E_a$ Range | | -------------------------------- | ------------------- | | Solid-state diffusion (graphite) | 20-40 kJ/mol | | Solid-state diffusion (NMC) | 30-60 kJ/mol | | Electrolyte conductivity | 10-20 kJ/mol | | Exchange current density | 20-50 kJ/mol | ### When Arrhenius Doesn't Apply The Arrhenius model assumes a single mechanism across all temperatures. It may fail when: * Phase transitions change the mechanism * Multiple processes compete at different temperatures * Non-thermal effects (concentration, stress) also matter For non-Arrhenius behavior, use [piecewise interpolation](/guide/calculations/piecewise) with temperature as the independent variable. ## Thermal Properties ### Heat Generation Batteries generate heat through several mechanisms: $$ \dot{Q} = \dot{Q}_{\text{reversible}} + \dot{Q}_{\text{irreversible}} $$ | Component | Formula | Description | | --------------------------- | ----------------------------------------------- | -------------------------------- | | Irreversible (Joule) | $I^2 R$ | Ohmic heating from current flow | | Irreversible (polarization) | $I \cdot \eta$ | Overpotential losses | | Reversible (entropic) | $I \cdot T \cdot \frac{\partial U}{\partial T}$ | Entropy change during lithiation | At high rates, irreversible heating dominates. At low rates, reversible heating can be significant and may cause local cooling during discharge. ### Heat Capacity The specific heat capacity $c_p$ determines temperature rise for a given heat input: $$ \Delta T = \frac{Q}{m \cdot c_p} $$ A `SpecificHeatCapacity` calculation converts between cell heat capacity \[J/K] and specific heat capacity \[J/(kg·K)] given the cell mass. ### Typical Thermal Property Values | Component | Heat Capacity (J/(kg·K)) | Thermal Conductivity (W/(m·K)) | | ------------------ | ------------------------ | ------------------------------ | | Graphite electrode | 700-900 | 1-5 (in-plane) | | NMC electrode | 700-1000 | 1-5 (in-plane) | | Separator | 1000-1400 | 0.3-0.5 | | Electrolyte | 1500-2000 | — | ## Lumped vs. Distributed Thermal Models Treats the cell as a single temperature: $$ m c_p \frac{dT}{dt} = \dot{Q} - h A (T - T_{\text{ambient}}) $$ **Use when**: Cell is small, gradients negligible, or fast simulation needed. A `LumpedHeatCapacityAndDensity` calculation combines the specific heat and density into the lumped heat-capacity term used by this model. Solves the heat equation with spatial variation: $$ \rho c_p \frac{\partial T}{\partial t} = \nabla \cdot (k \nabla T) + \dot{q} $$ **Use when**: Large cells, fast rates, or thermal gradients matter. ## Thermal Safety Thermal runaway occurs when heat generation exceeds dissipation, causing self-accelerating temperature rise. This is the primary safety concern for lithium-ion batteries. ### Onset Temperatures | Event | Typical Temperature | | --------------------- | ------------------- | | SEI decomposition | 90-120°C | | Separator shutdown | 130-150°C | | Thermal runaway onset | 150-200°C | Accurate thermal modeling helps design cells and systems that stay well below these thresholds. ## Practical Guidelines For initial modeling, lumped thermal with Arrhenius temperature dependence is usually sufficient. Add distributed thermal modeling only when investigating thermal management or large-format cells. ### Measurement Methods | Property | Method | | ------------------ | ------------------------------------------------------ | | Cell heat capacity | Accelerating rate calorimetry (ARC) | | Specific heat | Differential scanning calorimetry (DSC) | | Activation energy | Measurements at multiple temperatures + log-linear fit | # Array Data Fits Source: https://docs.ionworks.com/guide/data-fitting/array-data-fit Fit the same model separately at each value of an independent variable (e.g. temperature, pulse SOC) and return the fitted parameter as a 2xN array. An `ArrayDataFit` runs the same fit independently at each value of an **independent variable**, and returns the fitted parameter as a 2xN array — one row holding the independent-variable values and the other holding the corresponding fitted values. This is the right tool when the parameter you are fitting is expected to *depend* on a state variable, and you want a sampled function rather than a single scalar. Common examples: * **Diffusivity vs. stoichiometry** from a GITT or pulse experiment — one fit per pulse, keyed by the midpoint stoichiometry of that pulse. * **Exchange-current density or diffusivity vs. temperature** — one fit per chamber temperature. * **OCP-derived parameters vs. SOC** — one fit per SOC setpoint. ## How it differs from a regular `DataFit` A regular `DataFit` runs one optimization against all of its objectives *together*, producing a single best-fit value per parameter. An `ArrayDataFit` runs one optimization *per key* of `objectives`, producing a fitted value per key. The keys themselves are the independent-variable values. | | `DataFit` | `ArrayDataFit` | | -------------------------- | ---------------------------- | ---------------------------------------------------------- | | Number of optimizations | One | One per `objectives` key | | Result shape per parameter | Scalar | 2xN array (`[independent_var_values, fitted_values]`) | | Use when | Parameter is a single number | Parameter is a sampled function of an independent variable | ## Python usage ```python theme={null} import ionworks_schema as iws # objectives keyed by the independent-variable value (here: midpoint # stoichiometry of each pulse) objectives = { 0.25: iws.objectives.Pulse(data_pulse_25, options={"model": model}), 0.50: iws.objectives.Pulse(data_pulse_50, options={"model": model}), 0.75: iws.objectives.Pulse(data_pulse_75, options={"model": model}), } array_fit = iws.ArrayDataFit( objectives, parameters={ "Positive particle diffusivity [m2.s-1]": iws.Parameter( "D_p", initial_value=1e-14, bounds=(1e-15, 1e-13) ), }, ) config = array_fit.to_config() # Submit the config with client.pipeline.create(...) — see API usage below. # The fitted parameter comes back as a 2x3 array: # row 0 = [0.25, 0.50, 0.75], row 1 = fitted D_p values. ``` ## API usage The pipeline API accepts an `array_data_fit` element with the same top-level fields as `data_fit`. The keys of `objectives` are the independent-variable values: ```python theme={null} pipeline_config = { "elements": { "known values": {"element_type": "entry", "values": parameter_values}, "fit diffusivity vs sto": { "element_type": "array_data_fit", "objectives": { 0.25: {"objective": "Pulse", "data": "db:meas-pulse-25", ...}, 0.50: {"objective": "Pulse", "data": "db:meas-pulse-50", ...}, 0.75: {"objective": "Pulse", "data": "db:meas-pulse-75", ...}, }, "parameters": { "Positive particle diffusivity [m2.s-1]": { "bounds": [1e-15, 1e-13], "initial_value": 1e-14, }, }, }, }, } pipeline = client.pipeline.create(pipeline_config) ``` See the [Pipelines API how-to](/build/parameterize/api#simplepipeline) for the full set of options shared with `DataFit` (cost, optimizer, multistarts, priors, etc.). ## Related Background on objectives, parameters, and optimizers Choose the right objective for your data # Objective Functions Source: https://docs.ionworks.com/guide/data-fitting/objective-functions Cost function formulations for parameter fitting, including SSE, MSE, RMSE, and maximum likelihood estimation Objective functions define how the discrepancy between model predictions and experimental data is quantified during parameter fitting. ## Cost Function Formulations ### Residual Form (Array) The residual vector represents the difference between model predictions and data: $$ r(x) = y_{\text{model}}(x) - y_{\text{data}} $$ where $x$ represents the parameter vector. ### Canonical Form (Scalar) The canonical cost function aggregates the residuals into a single scalar value: $$ \Phi(x) \propto r(x)^\top r(x) = \sum_i^N r_i(x)^2 $$ This sum-of-squares formulation is fundamental to least-squares optimization. ## Common Cost Functions The basic sum of squared residuals: $$ \Phi_{\text{SSE}}(x) = \sum_i^N r_i^2 = r^\top r $$ This can be represented in both array and scalar forms, making it compatible with all optimization algorithms. Normalized by the number of data points: $$ \Phi_{\text{MSE}}(x) = \frac{1}{N} \sum_i^N r_i^2 $$ The square root of MSE: $$ \Phi_{\text{RMSE}}(x) = \sqrt{\frac{1}{N} \sum_i^N r_i^2} $$ RMSE cannot be represented in a residual array form because there is no mapping from residuals to the canonical scalar form. This limits its compatibility with some optimization algorithms. The Wasserstein distance (also known as earth mover's distance) measures the distance between two probability distributions. Intuitively, it is the minimum amount of "work" required to transform one distribution into the other. For one-dimensional samples, it is computed by sorting both arrays and comparing them point-wise: $$ \Phi_{\text{W}}(x) = \frac{1}{N} \sum_i^N \left| \tilde{y}_{\text{model},i}(x) - \tilde{y}_{\text{data},i} \right| $$ where $\tilde{y}$ denotes the sorted samples. Use Wasserstein when you care about matching the *distribution* of values (for example, a histogram of cell capacities or impedance magnitudes) rather than point-wise agreement in time. Because the inputs are sorted before comparison, the result is invariant to the ordering of the model and data arrays. **Weighted point-cloud mode.** When one variable supplies positions and another supplies weights — for example matching a dQ/dV curve where peaks should align in voltage — pass both `position_variable` and `weight_variable` to `iws.costs.Wasserstein`. The cost then computes a single weighted Wasserstein-1 distance per objective: $$ \Phi_{\text{W,weighted}}(x) = W_1\!\left(p_{\text{model}}, p_{\text{data}}, w_{\text{model}}, w_{\text{data}}\right) $$ where positions $p$ come from `position_variable` and the (sign-stripped, renormalised) weights $w$ come from `weight_variable`. Both must be set together. See [Pipelines → Data Fitting → Objective Functions](/build/parameterize/data-fitting/objective-functions#wasserstein-weighted-point-cloud-mode) for a configuration example. ## Maximum Likelihood Estimation (MLE) Under the assumption of independent, identically distributed Gaussian errors, the MLE cost function is: $$ r_{\text{MLE}}(x) = y_{\text{model}} - y_{\text{data}} $$ $$ \Phi_{\text{MLE}}(x) = \sum_i^N r_i^2 = r^\top r $$ This is equivalent to the sum squared error formulation, providing a probabilistic interpretation of least-squares fitting. For most optimization algorithms, the default sum-squared-error formulation provides the best compatibility and performance. Use MSE or RMSE when you need interpretable, scale-independent metrics. To pair an objective with a cost function in a data fit, see [Pipelines → Data Fitting → Objective Functions](/build/parameterize/data-fitting/objective-functions). # Introduction to Data Fitting Source: https://docs.ionworks.com/guide/data-fitting/overview Estimate battery model parameters from voltage and current measurements using least-squares optimization. Data fitting is the process of estimating unknown model parameters by minimizing the difference between model predictions and experimental measurements. ## The Data Fitting Problem Given experimental data and a model with unknown parameters, we seek parameters $\theta$ that minimize a cost function: $$ \hat{\theta} = \arg\min_\theta \; C(\theta) = \arg\min_\theta \sum_i \left( y_i^{\text{model}}(\theta) - y_i^{\text{data}} \right)^2 $$ In battery modeling, this typically means: * **Model**: Physics-based simulation (SPMe, DFN, etc.) * **Data**: Voltage, current, temperature measurements * **Parameters**: Diffusivities, reaction rates, capacities, etc. ## Core Components The data fitting framework has three main components: Define what to minimize: the difference between model and data Specify which parameters to fit and their bounds Choose how to search the parameter space ### Why Separate Components? Separating the **objective**, **cost**, and **optimizer** provides several benefits: * **Reusable objectives**: The same objective can be used with different cost functions (e.g., RMSE vs. feature extraction) * **Sequential refinement**: Objectives can be reused within a pipeline—first for approximate values, then for fine-tuning * **Combined objectives**: Multiple objectives can be combined to simultaneously optimize over different datasets (e.g., constant-current discharge at different C-rates or temperatures) ## Objectives An objective computes the cost for a given set of parameters. It: 1. Runs the model with the proposed parameters 2. Compares model output to experimental data 3. Returns a scalar cost value Multiple objectives can be combined for fitting to different experiments simultaneously. The most common objective for time-series voltage data is a current-driven objective, which feeds the measured current into the model and compares the predicted voltage against the measured voltage. ## Parameters Parameters define what the optimizer can adjust. Each parameter has: * **Initial value**: Starting point for optimization * **Bounds**: Physical limits on the parameter * **Transform**: Optional scaling (e.g., log-transform for diffusivities) ## Optimizers The optimizer searches for parameters that minimize the cost. | Optimizer | Best for | | --------------------- | ----------------------------------------------------------------- | | L-BFGS-B | Smooth problems, fast local optimization | | Nelder-Mead | Non-smooth problems, no gradients needed | | CMA-ES | Global optimization, many local minima | | DifferentialEvolution | Global optimization, parallelizable | | BayesianOptimization | Expensive simulations, ≤ \~10 parameters, small evaluation budget | | TuRBO | Expensive simulations evaluated in parallel batches | | SOBER | Wide parallel batches via quadrature-style selection | To configure and submit a data fit, see [Pipelines → Data Fitting](/build/parameterize/data-fitting/overview). ## Workflow Load experimental data and preprocess as needed Set up the physics-based model with fixed parameters Specify what data to fit and how to compare Choose which parameters to fit and their bounds Execute the fitting and analyze results ## Results A data fit returns the best-fit parameter values together with the final cost. When running with multiple starts, the framework records every run sorted by cost so you can inspect the spread and check whether the optimizer converged to the global optimum. ## Multi-Start Optimization For problems with multiple local minima, run optimization from different starting points. The framework automatically: * Generates initial guesses using Latin Hypercube sampling * Runs optimizations in parallel * Returns all results sorted by cost ## Related Topics Cost functions and objective types. Ridge regression and MAP estimation for stability. Understanding parameter identifiability. Fitting electrode stoichiometry windows. # Regularized Optimization Source: https://docs.ionworks.com/guide/data-fitting/regularization Stabilize battery parameter fitting with ridge regression, L2 penalties, and MAP estimation to address overfitting and ill-conditioning. Regularization techniques improve the stability and generalization of parameter estimation by adding penalty terms to the objective function. When fitting battery models, regularization helps address common challenges: noisy data, correlated parameters, and limited experimental conditions that leave some parameters poorly constrained. ## Why Regularization? Standard least-squares fitting minimizes the error between model predictions and data. However, this can lead to problems: * **Overfitting**: The optimizer finds parameter values that match noise in the training data, leading to poor predictions on new data * **Ill-conditioning**: When parameters are correlated (e.g., electrode thickness and diffusivity both affect time constants), small data perturbations cause large parameter swings * **Non-identifiability**: Some parameters may not be uniquely determined by the available data Regularization addresses these issues by penalizing extreme parameter values, effectively encoding prior knowledge that parameters should stay within reasonable ranges. ## Ridge Regression Ridge regression adds an L2 penalty (sum of squared parameter values) to the least-squares objective. This shrinks parameter estimates toward zero, reducing variance at the cost of introducing some bias. ### Problem Formulation The ridge regression objective is: $$ x_{\text{RR}}^* = \arg\min_x \sum_i^N r_i(x)^2 + \lambda \sum_j^M x_j^2 $$ with residuals: $$ r(x) = Ax - b $$ where: * $x \in \mathbb{R}^M$ – vector of parameters to estimate * $A \in \mathbb{R}^{N \times M}$ – design matrix (model predictions as a function of parameters) * $b \in \mathbb{R}^N$ – observed data * $\lambda \in [0, +\infty)$ – regularization strength The first term measures data fidelity (how well the model fits the data), while the second term penalizes large parameter values. The hyperparameter $\lambda$ controls the tradeoff: larger $\lambda$ means stronger regularization and more shrinkage toward zero. ### Normalization Requirement For the L2 penalty to treat all parameters equally, both the residuals and parameters must be on comparable scales. This is typically achieved by Z-scoring (standardizing to zero mean and unit variance): $$ \hat{A} = \frac{A - \text{mean}(A, \text{axis}=0)}{\text{std}(A, \text{axis}=0)} $$ $$ \hat{b} = \frac{b - \text{mean}(b)}{\text{std}(b)} $$ Without normalization, parameters with larger natural scales would be penalized more heavily, distorting the regularization. ## Hyperparameter Optimization The regularization strength $\lambda$ is a hyperparameter that must be chosen carefully. Too little regularization leaves the model prone to overfitting; too much regularization forces parameters away from their data-driven values, introducing bias. The goal is to find the $\lambda$ that best balances these competing effects. ### Bias-Variance Tradeoff Regularization introduces a fundamental tradeoff between bias and variance: * **Bias**: Regularization shrinks parameters toward the prior, pulling estimates away from the "true" values. This is the cost of regularization. * **Variance**: Without regularization, estimates are highly sensitive to noise in the training data. Regularization reduces this sensitivity. The optimal $\lambda$ minimizes the total error (bias² + variance) on unseen data: Bias-variance tradeoff | $\lambda$ Value | Training Error | Validation Error | Issue | | --------------------------- | -------------- | ---------------- | ------------------- | | Too small ($\lambda \to 0$) | Low | High | Overfitting | | Too large | High | High | Underfitting | | Optimal ($\lambda^*$) | Moderate | Low | Best generalization | ### Optimization Procedure For a fixed value of $\lambda$, determine $x_{\text{RR}}^*$ using the training data Compute the prediction error on the validation set Iterate steps 1-2 for several $\lambda$ values Choose $\lambda^*$ that minimizes validation error, then refit on combined training and validation data ## Maximum A Posteriori (MAP) Estimation While ridge regression shrinks parameters toward zero, we often have better prior knowledge—for example, literature values or physical constraints. MAP estimation with Gaussian priors generalizes ridge regression by shrinking parameters toward specified prior means rather than zero. From a Bayesian perspective, MAP estimation finds the parameter values that maximize the posterior probability given the data. With Gaussian priors and Gaussian measurement noise, this is equivalent to minimizing: $$ x_{\text{MAP}}^* = \arg\min_x \sum_i^N \left(\frac{\hat{y}_i(x) - y_i}{\sigma_{y,i}}\right)^2 + \sum_j^M \left(\frac{x_j - \mu_j}{\sigma_{x,j}}\right)^2 $$ where: * $\hat{y}_i(x)$ – model prediction at data point $i$ * $y_i$ – observed data at point $i$ * $\sigma_{y,i}$ – measurement uncertainty (standard deviation) * $\mu_j$ – prior mean for parameter $j$ (e.g., literature value) * $\sigma_{x,j}$ – prior uncertainty for parameter $j$ The first term is the normalized data misfit (chi-squared statistic). The second term penalizes deviations from prior expectations, weighted by prior uncertainty. Parameters with tight priors (small $\sigma_{x,j}$) are constrained more strongly. ### Connection to Ridge Regression MAP estimation is mathematically equivalent to ridge regression when parameters are centered at the prior mean and scaled by the prior standard deviation. Adding a regularization hyperparameter $\lambda$ gives: $$ x_{\text{MAP,RR}}^* = \arg\min_x \sum_i^N \left(\frac{\hat{y}_i(x) - y_i}{\sigma_{y,i}}\right)^2 + \lambda \sum_j^M \left(\frac{x_j - \mu_j}{\sigma_{x,j}}\right)^2 $$ When $\lambda = 1$, this is standard MAP estimation. When $\lambda < 1$, the data is weighted more heavily relative to the priors. When $\lambda > 1$, the priors dominate. ### Efficient Nonlinear Regularization For linear models, ridge regression has an analytic solution. Nonlinear models (like battery electrochemical models) require iterative optimization, and finding the optimal $\lambda$ through cross-validation would require repeated refitting—computationally expensive. An efficient alternative leverages two key assumptions: 1. **All parameters have priors**: Every parameter has a specified prior distribution, eliminating identifiability issues where multiple parameter combinations give equivalent fits. 2. **Local quadratic approximation**: Near the optimum $x_{\text{MAP}}^*$, the objective function is approximately quadratic. This is valid when optimization has converged to a well-defined minimum. Under these assumptions, the Hessian at the optimum characterizes the local curvature, and the optimal $\lambda^*$ can be determined efficiently from a single optimization run plus validation error evaluation—without repeatedly refitting the full model. ## Practical Usage In practice, regularization is configured by attaching Gaussian priors to the parameters being fit. The prior mean represents your best estimate before seeing data, and the prior standard deviation encodes your uncertainty. To configure priors and run a regularized data fit, see [Pipelines → Data Fitting → Regularization](/build/parameterize/data-fitting/regularization). ### Choosing Priors Good priors come from: * **Literature values**: Published measurements for similar materials * **Physical constraints**: Known bounds from theory (e.g., diffusivity must be positive) * **Previous experiments**: Results from related cells or conditions * **Order-of-magnitude estimates**: Even rough estimates help stabilize fitting The prior standard deviation should reflect genuine uncertainty. A narrow prior (small $\sigma$) strongly constrains the parameter; a wide prior (large $\sigma$) allows the data to dominate. When uncertain about prior strength, start with wide priors (large $\sigma$) and tighten them only if fitting becomes unstable. Overly tight priors can prevent the optimizer from finding good solutions. # Sensitivity Analysis Source: https://docs.ionworks.com/guide/data-fitting/sensitivity-analysis Quantify how input parameters influence model outputs using variance-based SOBOL indices and the SALib library Sensitivity analysis quantifies how changes in input parameters affect model outputs. The implementation uses variance-based SOBOL indices through the SALib library to identify which parameters most strongly influence your model predictions. ## Key Concepts Direct contribution of each parameter to output variance. An S1 of 0.3 means that parameter alone accounts for 30% of variance. Direct effects plus all interaction effects involving that parameter. ST - S1 indicates interaction strength. Pairwise interactions between parameters. Computationally expensive but reveals parameter coupling. ## Workflow Sensitivity analysis is run after fitting a model: the fit defines the parameter ranges, and the analysis samples those ranges and aggregates how much each parameter influences the model output. Sensitivity analysis is a **post-fit, in-process** capability of `ionworkspipeline`. It is not exposed as an `ionworks-schema` element or an `ionworks-api` endpoint, so it cannot be added to a pipeline config you submit to Ionworks. ## Understanding Results The analysis returns SOBOL indices in the form: ``` { "S1": [0.65, 0.25], # First-order indices "S1_conf": [0.05, 0.04], # 95% confidence intervals "ST": [0.72, 0.35], # Total-order indices "ST_conf": [0.06, 0.05], # 95% confidence intervals } ``` ### Interpretation Example For `S1 = [0.65, 0.25]` and `ST = [0.72, 0.35]`: | Parameter | Direct Effect (S1) | Interaction Effect (ST - S1) | Total Effect (ST) | | ----------- | ------------------ | ---------------------------- | ----------------- | | Parameter 1 | 65% | 7% | 72% | | Parameter 2 | 25% | 10% | 35% | * **Unaccounted variance:** 1 - sum(S1) = 10% from joint interactions * **Total effects sum > 1:** Because interaction contributions involve both parameters If ST ≈ 0 for a parameter, it has negligible influence on model outputs within the specified bounds. Consider fixing that parameter or checking if bounds are physically realistic. ## Configuration Options ### Sample Size The SOBOL algorithm generates `n_samples × (2 × n_params + 2)` evaluations. Recommendations: * Use powers of 2: 256, 512, 1024 * Larger values reduce confidence intervals but increase computation time * Start with 256 for exploratory analysis ### Second-Order Indices Element `S2[i,j]` quantifies the interaction between parameters `i` and `j` beyond their individual effects. Computing it requires more samples but reveals which parameters are coupled. ## Practical Workflow Run with a moderate sample size (256) to identify important parameters. Parameters with ST \< 0.05 are typically negligible. For important parameters, increase the sample size (e.g. 1024) to tighten confidence intervals. If ST − S1 is large for multiple parameters, enable second-order indices to find which pairs are coupled. ## Handling Failed Evaluations If model evaluations fail for some parameter combinations, the analysis will warn — e.g. "15 out of 853 evaluations (1.76%) failed". If many evaluations fail (>5%): * Check parameter bounds for physical validity * Verify model stability across the parameter space * Consider tightening bounds around the fitted values ## Log-Transform Option If the cost landscape has an extremely large range, apply a log transform during the analysis. With a log transform, the sensitivity represents multiplicative changes in cost due to parameters, rather than additive changes. ## Mathematical Background SOBOL indices are variance-based measures that decompose the total output variance into contributions from individual parameters and their interactions. This approach treats the model as a black box and samples the entire parameter space, making it a *global* sensitivity method—in contrast to local methods like gradient-based sensitivity that only characterize behavior near a single point. ### Variance Decomposition Consider a model $Y = f(X_1, X_2, \ldots, X_p)$ where $X_i$ are independent input parameters. The total variance of the output can be decomposed as: $$ V(Y) = \sum_i V_i + \sum_{i Use sensitivity analysis before extensive MCMC sampling to identify which parameters are worth the computational investment. ## References 1. Herman, J., & Usher, W. (2017). SALib: An open-source Python library for Sensitivity Analysis. *Journal of Open Source Software*, 2(9), 97. 2. Saltelli, A., et al. (2010). Variance based sensitivity analysis of model output. *Computer Physics Communications*, 181(2), 259-270. # Voltage and Stoichiometry Limits Source: https://docs.ionworks.com/guide/data-fitting/voltage-stoichiometry-limits Distinguish cut-off voltages, OCV endpoints, and stoichiometry ranges used as simulation bounds and parameterization references. When configuring a battery model, you may encounter several types of voltage and stoichiometry limits. This guide clarifies what each set of limits means, how they are used in simulation and parameter fitting, and why they all exist. ## Voltage Limits Voltage limits define the operating range of the cell. These values are critical for safety, model realism, and aligning with experimental protocols. ### Cut-off Voltages * **`Lower voltage cut-off`** / **`Upper voltage cut-off`** These are the actual voltage limits of the cell, typically set by the manufacturer or safety requirements. They define the voltage range within which the cell is cycled and are used as **hard simulation limits**. ### OCV at SOC Endpoints * **Open-circuit voltage at 0% / 100% SOC** These correspond to the stoichiometric endpoints of the electrodes. They define the open-circuit voltage at full lithiation or delithiation and are used to determine the **stoichiometry range during parameterization**. They should always lie within the upper and lower voltage cut-off values. These two sets of voltage values often appear similar and typically take the same value, but serve different purposes: * **Cut-off voltages** are operational bounds * **OCV endpoints** are parameterization references for stoichiometry ## Stoichiometry Limits Stoichiometry describes the relative lithium content in each electrode and is central to determining electrode balancing and usable lithium. ### Stoichiometry at 0%/100% SOC * **Negative/positive electrode stoichiometry at 0%/100% SOC** These define the electrode stoichiometries corresponding to 0% and 100% state of charge based on the full-cell OCV curve. They are used to compute: * Initial lithium concentrations * Electrode balancing **These are the main stoichiometry bounds used in simulations.** ### Stoichiometry at Min/Max SOC * **Negative/positive electrode stoichiometry at minimum/maximum SOC** These reflect the range of stoichiometry values *observed in the data used for fitting*. They are **not used in simulation**, only during parameter estimation. These parameters provide better numerical convergence during fitting and help guide the optimization algorithm. ### Lower/Upper Excess Capacity * **Negative/positive electrode lower/upper excess capacity** These are alternative fitting parameters that reflect unused lithiation capacity outside the typical operating range. Like min/max stoichiometry, they are **internal to the fitting process** and not exposed during model evaluation. ## How These Fit Together Obtain OCV data from the full cell Use OCV data to determine min/max stoichiometry for each electrode Calculate the stoichiometry limits corresponding to OCV at 0%/100% SOC Use stoichiometry limits to compute initial concentrations and cyclable lithium Set cut-off voltages to define where the cell will actually be cycled ## Stoichiometry Sign Convention **Key Design Principle:** Stoichiometry always increases as voltage decreases for a given electrode. | Electrode | SOC | Stoichiometry | Reason | | ------------ | ---- | ------------- | ---------------------------- | | **Negative** | 0% | Lower | Delithiated during discharge | | **Negative** | 100% | Higher | Lithiated during charge | | **Positive** | 0% | Higher | Lithiated during discharge | | **Positive** | 100% | Lower | Delithiated during charge | This consistent convention helps maintain orientation across models. ## Summary Table | Parameter Type | Used In | Purpose | | ---------------------------- | ---------------- | -------------------------------------------- | | Cut-off voltages | Simulation | Operational safety bounds | | OCV at 0%/100% SOC | Parameterization | Stoichiometry range reference | | Stoichiometry at 0%/100% SOC | Simulation | Initial concentrations, electrode balance | | Stoichiometry at min/max SOC | Fitting only | Numerical convergence, optimization guidance | | Excess capacity | Fitting only | Unused lithiation capacity | While having multiple definitions may seem excessive, each serves a specific role in maintaining model fidelity, improving fitting stability, and aligning with experimental protocols. # Introduction Source: https://docs.ionworks.com/guide/introduction Technical guide to battery electrochemistry, PyBaMM modeling, parameterization, data fitting, and design optimization. This Technical Guide covers the science behind lithium-ion batteries and battery modeling — electrochemistry, numerical methods, parameter identification, and design optimization. The content is vendor-neutral and aimed at building intuition rather than walking through any specific tooling. For hands-on workflows using the Ionworks stack (pipelines, simulations, optimizations, the Python API), see the [Documentation tab](/introduction). ## Table of Contents ### Batteries 101 Learn the fundamentals of lithium-ion battery science, from basic electrochemistry to system-level considerations. How batteries work, electrode essentials, open circuit voltage, and reaction kinetics Battery capacity, state of charge, internal resistance, and thermal behavior State of health, degradation mechanisms, SEI growth, lithium plating, and mechanical effects Battery packs and battery management systems ### Modeling Understand the numerical methods and models used for battery simulation. How PyBaMM discretizes PDEs into systems of ODEs and DAEs Model initialization, electrode state of health, and initial concentrations Multi-Species Multi-Reaction thermodynamic model for OCP and diffusivity The four binary-electrolyte transport properties and how they are measured ### Parameterization Parameter calculations, data fitting, and workflow orchestration concepts. Fire-and-forget execution for single-datafit or single-validation workflows How pipelines chain direct entries, calculations, and data fits to transform parameters Drop-in literature parameter sets, including electrolyte transport properties Estimating model parameters from experimental data ### Optimization Design optimization for battery performance targets. Optimizing design parameters to achieve performance objectives ### Reference Terminology and conventions used throughout the documentation. Battery modeling terminology and conventions # Electrolyte transport properties Source: https://docs.ionworks.com/guide/modeling/electrolyte-transport The four transport properties that close a binary-electrolyte DFN model, their role in the equations, and how they are measured experimentally A continuum (Doyle–Fuller–Newman) lithium-ion model treats the electrolyte as a **binary salt in a single solvent** and tracks a single salt concentration $c_e$. Closing the equations requires four transport properties as functions of $c_e$ and temperature $T$: | Property | Symbol | Units | Role in the model | | -------------------------- | ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------- | | Ionic conductivity | $\kappa(c_e, T)$ | S m$^{-1}$ | Ohmic drop in the electrolyte phase | | Salt diffusion coefficient | $D_e(c_e, T)$ | m$^{2}$ s$^{-1}$ | Concentration polarization | | Thermodynamic factor | $\chi(c_e, T)$ | – | Activity-coefficient correction $\bigl(1 + \tfrac{\partial \ln f_\pm}{\partial \ln c_e}\bigr)$ to the diffusional driving force | | Cation transference number | $t_+^0(c_e, T)$ | – | Fraction of current carried by Li$^+$ in the electrolyte | The four functions appear together in the modified Nernst–Planck flux for a binary electrolyte and are tightly coupled — a coherent parameter set should report all of them under the same conditions, on the same chemistry, ideally from the same study. ## How are these properties measured? [Landesfeind & Gasteiger 2019](https://doi.org/10.1149/2.0571912jes) is one of the few studies that reports all four properties for the same electrolyte systems, using the following techniques. Measured by **AC impedance spectroscopy** in a commercial two-platinum-microelectrode conductivity cell with a Peltier-controlled temperature stage. The high-frequency ($85$ kHz – $1$ kHz) impedance gives the bulk electrolyte resistance, which combined with the cell constant (calibrated against KCl standards) yields $\kappa$. Measured by **galvanostatic-pulse / restricted-diffusion relaxation** in Li $\mid$ separator $\mid$ Li symmetric coin cells. A 15-minute current pulse builds up a concentration gradient across the separator; after the current is interrupted, the cell potential decays exponentially over $\sim 4$ hours. Fitting the long-time decay $U(t) - U_\infty \propto e^{-t/\tau}$ gives a relaxation time $\tau$, from which $D_{e,\mathrm{eff}} = \ell_\mathrm{sep}^2 / (\pi^2 \tau)$. These two are extracted **together** from a combination of two measurements: * **Concentration cells** (pouch cells with two electrolyte compartments at different LiPF$_6$ concentrations) give the open-circuit potential $U_\mathrm{CC}$, which is sensitive to the activity coefficient and to $t_+^0$. * The **short-time potential response** of the same Li $\mid$ Li pulse experiment used for $D_e$ gives a complementary combination of the two quantities. Together they yield two transport factors $a$ and $b$, which Landesfeind invert via $t_+^0 = 1 - \sqrt{b/a}$ and $\chi = a^2 / (2b)$ — a variant of [Newman's full-cell method](https://doi.org/10.1149/1.2086525). These are the techniques used in Landesfeind & Gasteiger 2019, but several other experimental approaches exist (Hittorf, NMR-based diffusion, electrophoretic NMR for $t_+^0$, polarization-relaxation variants, etc.). For a comprehensive review of available methods and a database of digitized literature parameters, see [Wang et al. 2022, *Review of parameterisation and a novel database (LiionDB) for continuum Li-ion battery models*, Prog. Energy 4, 032004](https://doi.org/10.1088/2516-1083/ac692c). ## Using these properties in a model A small set of pre-built **direct entries** is available for the common literature parameterizations (constant, Nyman, Landesfeind, Arrhenius wrappers). They drop a coherent parameter set straight into a pipeline. For the physics and choice of parameterization see [Electrolyte direct entries](/guide/pipelines/direct-entries/electrolyte); to configure and submit one programmatically, see [Pipelines → Direct Entries](/build/parameterize/direct-entries). # The Finite Volume Method Source: https://docs.ionworks.com/guide/modeling/finite-volume-method How PyBaMM uses the finite volume method to discretize battery PDEs into ODEs and DAEs for numerical solution. PyBaMM uses the Finite Volume Method (FVM) to convert systems of partial differential equations (PDEs) into systems of ordinary differential equations (ODEs) and differential algebraic equations (DAEs). ## Overview The FVM discretizes the domain into a series of "volumes" and tracks the average value of each state variable $u$ across each volume. ``` +------------+------------+------------+ | | | | | u(i-1) | u(i) | u(i+1) | | | | | +------------+------------+------------+ ^ ^ ^ x(i-1) x(i) x(i+1) ^ ^ x(i-1/2) x(i+1/2) | | J(i-1/2) J(i+1/2) ``` Each volume $v_i$ is centered at position $x_i$, with interfaces at $x_{i-\frac{1}{2}}$ and $x_{i+\frac{1}{2}}$. The state variable $u_i$ represents the average of $u$ over that volume: $$ u_i = \frac{1}{v_i} \int_\Omega u $$ ## Flux Calculation Fluxes at each interface are calculated via the central difference method. For the interface at $x_{i-\frac{1}{2}}$, the flux $J_{i-\frac{1}{2}}$ is given by: $$ J_{i-\frac{1}{2}} = D_{i-\frac{1}{2}} \frac{u_i - u_{i-1}}{x_i - x_{i-1}} $$ where $D_{i-\frac{1}{2}}$ is the harmonic mean of $D_i$ and $D_{i-1}$. We use the harmonic mean to find the values of transport properties at interfaces, and the arithmetic mean for other properties. ## Rate of Change The rate of change of $u_i$ is calculated by taking the boundary integral of the fluxes in and out of each volume: $$ \frac{du_i}{dt} = \frac{1}{v_i} \oint N(u) \cdot \mathbf{n} \, dS $$ For the 1D case, this simplifies to: $$ \frac{du_i}{dt} = \frac{J_{i-\frac{1}{2}} - J_{i+\frac{1}{2}}}{\Delta x_i} $$ ## Extrapolation and Boundary Values One important consequence of using the finite volume method is that we do not directly calculate the value of state variables at the boundaries of the domain. For example, when simulating a battery, we do not directly calculate the potential of the positive electrode at the current collector (i.e., the terminal voltage of the cell). Instead, we must extrapolate from the nearest volume. ### Extrapolation Methods Uses the value from the nearest volume directly Linearly extrapolates from the two volumes nearest the boundary (default in PyBaMM) Fits a quadratic polynomial to the three volumes nearest the boundary All of these extrapolations incur some error, which depends on: * The steepness of the state variable near the boundary * The size of the mesh For maximum accuracy, we recommend using the quadratic extrapolation. Linear extrapolation is the default in PyBaMM for legacy reasons. # Initialization & Electrode State of Health Source: https://docs.ionworks.com/guide/modeling/initialization-esoh Set initial lithium distributions and use the ESOH algorithm to find stoichiometry windows mapping cell SOC to electrode lithiation. Before running a simulation, you need to specify how lithium is distributed in each electrode. This determines the starting state of charge and affects the entire simulation. The Electrode State of Health (ESOH) algorithm finds the stoichiometry windows that map cell SOC to electrode lithiation states. ## Why Initialization Matters The initial concentration distribution affects: * **Starting voltage**: The cell begins at the OCV corresponding to the initial stoichiometry * **Available capacity**: How much charge can be extracted before hitting voltage limits * **Simulation accuracy**: Incorrect initialization leads to incorrect predictions throughout the simulation ## The ESOH Problem When you measure a full-cell OCV curve, you observe the difference between electrode potentials: $$ V_{\text{cell}}(z) = U_p(\theta_p) - U_n(\theta_n) $$ But the cell voltage alone doesn't tell you the individual electrode stoichiometries. The ESOH algorithm solves this inverse problem by finding the stoichiometry windows that explain the full-cell OCV. ### What ESOH Determines | Parameter | Meaning | | ---------------- | ---------------------------------- | | $\theta_{n,0}$ | Negative stoichiometry at 0% SOC | | $\theta_{n,100}$ | Negative stoichiometry at 100% SOC | | $\theta_{p,0}$ | Positive stoichiometry at 0% SOC | | $\theta_{p,100}$ | Positive stoichiometry at 100% SOC | These define how each electrode is utilized across the SOC range—essential for accurate simulation and degradation tracking. ## Calculation of Minimum and Maximum Stoichiometries First, the minimum and maximum stoichiometries in each electrode (based on the voltage range) must be calculated. ### Required Parameters * `Initial concentration in electrode [mol.m-3]` ($c_{s,t=0}^{+,-}$) * `Voltage at 100% SOC [V]` ($V_{100}$) * `Voltage at 0% SOC [V]` ($V_0$) ### System of Equations The stoichiometries are found by solving: $$ U^+(x_{100}, T) - U^-(y_{100}, T) - V_{100} = 0 $$ $$ U^+(x_0, T) - U^-(y_0, T) - V_0 = 0 $$ where $x$ and $y$ are the stoichiometries of the positive and negative electrodes respectively, the subscript indicates the SOC percentage, $T$ is temperature, and $U$ is the open-circuit potential function. ### Cyclable Lithium Constraint By default, cyclable lithium inventory ($Q_{Li}$) is used as the constraint to fully define the system: $$ y_{100} = \frac{Q_{Li} - x_{100} \cdot Q^-}{Q^+} $$ $$ y_0 = y_{100} + \frac{Q}{Q^+} $$ where $Q$ is given by: $$ Q = Q^- \cdot (x_{100} - x_0) $$ ### Electrode Capacity Calculation Each electrode capacity is calculated as: $$ Q^{+,-} = A^{+,-} \cdot c_{s,0}^{+,-} \cdot T^{+,-} \cdot \varepsilon_{am}^{+,-} $$ where $A$ is electrode area, $T$ is electrode thickness, and $\varepsilon_{am}$ is the active material volume fraction. ## Initialization Options ### Directly Specify Initial Concentration The solver uses the value corresponding to `Initial concentration in electrode [mol.m-3]` in the parameters object. To change the value, simply modify this parameter. ### Specify Initial State of Charge Initial concentrations are defined using the specified initial SOC and the min/max stoichiometries. For the positive electrode: $$ c_{s,t=0}^+ = c_{s,max}^+ \left( x_0 + \frac{SOC}{100}(x_{100} - x_0) \right) $$ For the negative electrode: $$ c_{s,t=0}^- = c_{s,max}^- \left( y_0 + \frac{SOC}{100}(y_0 - y_{100}) \right) $$ ### Specify Initial Open-Circuit Voltage One additional equation is added using an initial voltage $V_{init}$: $$ U^+(x, T) - U^-(y, T) - V_{init} = 0 $$ where $x$ and $y$ are related similarly to the constraint equations. ## Practical guidance We generally recommend setting the initial concentrations based on an initial voltage observed in the data — typically the last voltage in the step before the first step that's being fit. This approach is suitable when the step before the first step to be fit is a long rest step. ## References 1. Mohtat, Peyman, et al. "Towards better estimability of electrode-specific state of health: Decoding the cell expansion." *Journal of Power Sources* 427 (2019): 101-111. 2. Weng, Andrew, et al. "Modeling battery formation: Boosted sei growth, multi-species reactions, and irreversible expansion." *Journal of The Electrochemical Society* 170.9 (2023): 090523. # Ionworks DAE Solver Source: https://docs.ionworks.com/guide/modeling/ionworks-solver The fast default solver behind every simulation on the platform, with automatic fallback to IDAKLU. The **Ionworks DAE Solver** (`IonworksSolver`) is a faster drop-in replacement for PyBaMM's `IDAKLUSolver`. It returns the same results as IDAKLU and falls back to it automatically for models it can't accelerate. It is the platform's **default solver** for every pipeline submission and SDK simulation — there is nothing to configure. The fast path currently supports fixed output time steps only, with linear interpolation between the `t_eval` and `t_interp` points. Models with events (such as voltage cut-offs) are run through IDAKLU instead. # Multi-Species Multi-Reaction (MSMR) Model Source: https://docs.ionworks.com/guide/modeling/msmr-model Theoretical foundations and application of the MSMR model for thermodynamically consistent lithium insertion electrode characterization. The Multi-Species Multi-Reaction (MSMR) model provides a thermodynamically consistent framework for describing lithium insertion electrodes. This guide covers the theoretical foundations and practical applications. ## Electrode Model Equations ### Thermodynamics The MSMR model assumes that all electrochemical reactions at the electrode/electrolyte interface in a lithium insertion cell can be expressed as: $$ \text{Li}^+ + \text{e}^- + \text{H}_j \rightleftharpoons (\text{Li--H})_j $$ For each species $j$, a vacant host site $\text{H}_j$ can accommodate one lithium, leading to a filled host site $(\text{Li--H})_j$. ### Open-Circuit Voltage The OCV for each reaction is written as: $$ U_j = U_j^0 + \frac{\omega_j}{f} \log\left(\frac{X_j - x_j}{x_j}\right) $$ where: * $f = F/(RT)$ with $R$, $T$, and $F$ being the universal gas constant, temperature, and Faraday's constant * $X_j$ is the total fraction of available host sites for species $j$ * $x_j$ is the fraction of filled sites occupied by species $j$ * $U_j^0$ is the concentration-independent standard electrode potential * $\omega_j$ is a dimensionless parameter describing the disorder level ### Inverse Form The equation can be inverted to give: $$ x_j = \frac{X_j}{1 + \exp[f(U - U_j^0)/\omega_j]} $$ The overall electrode stoichiometry is: $$ x = \sum_j x_j = \sum_j \frac{X_j}{1 + \exp[f(U - U_j^0)/\omega_j]} $$ This provides an explicit closed-form expression for the inverse of the OCV, which is the opposite of many battery models that give OCV as an explicit function of stoichiometry. ### Kinetics The kinetics of the insertion reaction are given by: $$ i_j = i_{0,j}[e^{(1-\alpha_j)f\eta} - e^{-\alpha_j f\eta}], \quad i = \sum_j i_j $$ where $\eta = \phi_s - \phi_e - U(x)$ is the overpotential, and the exchange current density is: $$ i_{0,j} = i_{0,j}^{ref} (x_j)^{\omega_j \alpha_j} (X_j - x_j)^{\omega_j(1-\alpha_j)} (c_e/c_e^{ref})^{1-\alpha_j} $$ ### Solid Phase Transport Within the MSMR framework, the flux within particles is expressed in terms of the chemical potential gradient: $$ N = -c_T x \frac{D}{RT} \nabla\mu + x(N + N_H) $$ Ignoring volumetric expansion ($N + N_H = 0$), this simplifies to: $$ N = c_T f D x(1-x) \frac{dU}{dx} \nabla x $$ The mass balance becomes: $$ \frac{\partial x}{\partial t} = -\nabla \cdot \left( x(1-x) f D \frac{dU}{dx} \nabla x \right) $$ ### Boundary Conditions For a radially symmetric spherical particle: $$ N\big|_{r=0} = 0, \quad N\big|_{r=R} = \frac{i}{F} $$ where $R$ is the particle radius. To avoid evaluating $U(x)$ and $dU/dx$ explicitly, we transform to use $U$ as the dependent variable, yielding: $$ \frac{dU}{dx} \frac{\partial U}{\partial t} = -\nabla \cdot \left( x(1-x) f D \nabla x \right) $$ ## Fitting OCP Functions to Data Fitting OCP models to data presents three key challenges. ### The Missing-Data Problem At any point in time, the measured half-cell terminal voltage can be modeled as: $$ V(t) = U_{\text{ocp}}(x(t)) + \text{hysteresis} - \text{impedance} \times \text{current} $$ As a result, the half cell never lithiates all the way to $U_{\text{ocp}} = V_{\text{min}}$ even though the measured terminal voltage reaches $V_{\text{min}}$. The horizontal gaps between discharge and charge curves at the voltage limits represent the "missing-data problem." ### The Inaccessible-Lithium Problem Laboratory tests only cycle between $V_{\text{min}}$ and $V_{\text{max}}$, meaning: * Absolute stoichiometry $\theta_s \neq 1$ at $V_{\text{min}}$ * Absolute stoichiometry $\theta_s \neq 0$ at $V_{\text{max}}$ There are portions of active materials never accessed by OCP tests since we cannot achieve high-enough or low-enough voltages in practical laboratory tests. ### MSMR as a Solution By regressing independent parameter sets for discharge and charge: $$ \{U_j^0, X_j, \omega_j, \theta_{\text{min}}, \theta_{\text{max}}\}_{\text{dis}} $$ $$ \{U_j^0, X_j, \omega_j, \theta_{\text{min}}, \theta_{\text{max}}\}_{\text{chg}} $$ We can estimate the underlying OCP by averaging the two parameter sets, which are now expressed in terms of absolute degree of lithiation. ## Generating Standard U(x) Curves Unlike typical OCP models, MSMR gives stoichiometry as a function of voltage. To generate lookup tables: 1. Evaluate the MSMR model over a given voltage range 2. Specify the number of evaluation points 3. Use the resulting arrays to construct an interpolant for other models ## Full-Cell Balance Using half-cell parameters, we perform electrode balance to get the full-cell OCP: $$ U_{\text{cell}}(z) = U_p(\theta_p) - U_n(\theta_n) $$ where $z$ is full-cell SOC and $\theta_n$, $\theta_p$ are electrode stoichiometries. The conversion between stoichiometry and SOC is: $$ z = \frac{\theta_n - \theta_n^0}{\theta_n^{100} - \theta_n^0} = \frac{\theta_p - \theta_p^0}{\theta_p^{100} - \theta_p^0} $$ ### Capacity-Based Formulation In practice, full-cell OCV data is given in terms of capacity. The reformulation: $$ U_{\text{cell}}(q) = U_p(q_p) - U_n(q_n) $$ where $q = Qz$, $q_n = \theta_n Q_n$, $q_p = \theta_p Q_p$. We fit the "lower excess capacity" and "upper excess capacity" instead of stoichiometries at 0% and 100% SOC, as the data typically does not reach the true endpoints. ## Blended MSMR / Experimental OCP The `OCPDataInterpolantMSMRExtrapolation` calculation creates a blended OCP interpolant that combines: * Experimental data (in the measured range) * MSMR model extrapolation (outside the measured range) ### Blending Function $$ V(x) = w(x) \cdot V_{\text{ocp}}(x) + (1 - w(x)) \cdot V_{\text{msmr-corrected}}(x) $$ where $w(x)$ is a $C^\infty$ bump function that transitions from $\approx 1$ inside the data range to $\approx 0$ outside it. The `transition_fraction` parameter (typically 0.05-0.15) controls the transition region width, balancing data fidelity and smoothness. ## Diffusivity in MSMR The solid-phase transport equation within MSMR includes a thermodynamic factor: $$ N = c_T f D x(1-x) \frac{dU}{dx} \nabla x $$ The term $\frac{dU}{dx}$ can be computed directly from the MSMR parameters, making MSMR particularly useful for modeling concentration-dependent diffusivity. ### SOC-Dependent Diffusivity Diffusivity often varies significantly with state of charge—by 2-3 orders of magnitude in graphite near phase transitions. For empirical SOC-dependent diffusivity without MSMR, use [piecewise interpolation](/guide/calculations/piecewise). ### Characteristic Diffusion Time The diffusion time scale determines rate capability: $$ \tau_D = \frac{R^2}{D} $$ where $R$ is particle radius. At 1C, $\tau_D$ should be roughly 1 hour for the diffusion process to keep up with the electrochemistry. ## References 1. Verbrugge, Mark, et al. "Thermodynamic model for substitutional materials: application to lithiated graphite, spinel manganese oxide, iron phosphate, and layered nickel-manganese-cobalt oxide." *Journal of The Electrochemical Society* 164.11 (2017): E3243. 2. Lu, Dongliang, et al. "Implementation of a physics-based model for half-cell open-circuit potential and full-cell open-circuit voltage estimates: part I. Processing half-cell data." *Journal of The Electrochemical Society* 168.7 (2021): 070532. # Electrolyte direct entries Source: https://docs.ionworks.com/guide/pipelines/direct-entries/electrolyte Direct entries that set the four electrolyte transport properties for a DFN model from literature values or as fit unknowns. A direct entry populates parameter values without doing any calculation or fitting — useful for dropping a coherent literature parameter set into a pipeline. The four electrolyte direct entries cover the [four electrolyte transport properties](/guide/modeling/electrolyte-transport) needed by a binary-electrolyte DFN model. ## Available entries Just sets the initial salt concentration; everything else is left to defaults or other entries. Full set for LiPF$_6$ in EC:EMC from [Nyman et al. 2008](https://doi.org/10.1016/j.electacta.2008.04.023). Concentration-dependent $\kappa$ and $D_e$, constant $\chi=1$ and $t_+^0 = 0.2594$. Isothermal. Full set with concentration **and** temperature dependence for three solvent systems (`EC:DMC (1:1)`, `EC:EMC (3:7)`, `EMC:FEC (19:1)`) from [Landesfeind & Gasteiger 2019](https://doi.org/10.1149/2.0571912jes). `arrhenius_electrolyte_diffusivity` and `arrhenius_electrolyte_conductivity` wrap a reference $D_e(c_e)$ or $\kappa(c_e)$ in an Arrhenius temperature factor. Useful when you have isothermal data and need to bolt on $T$-dependence. A direct entry returns the initial salt concentration plus all four transport-property functions and the coefficients those functions reference. Plugging it into a pipeline is enough to fully specify the electrolyte block of a DFN model. ## Fitting your own coefficients The six conductivity coefficients (and the diffusivity, thermodynamic-factor, and transference-number coefficients) inside `landesfeind_electrolyte` are exposed as named parameters precisely so they can be replaced with fit unknowns. Override the relevant parameter names in the data-fit `parameters` dict and run a pipeline that includes the direct entry — the published values act as the base and the optimizer searches over the overridden ones. To configure and submit the Landesfeind/Nyman direct entries with `ionworks-schema` + `ionworks-api`, see [Pipelines → Direct Entries](/build/parameterize/direct-entries). ## Building transport from your own data If you already have measured transport properties versus concentration for your electrolyte, you can skip the literature parameterisations and build a custom electrolyte direct entry from a [material property dataset](/data/materials) with `client.electrolyte.transport_from_dataset()`. Each property can be returned as a tabulated interpolant or as a fitted isothermal Landesfeind form — see [Pipelines → Direct Entries](/build/parameterize/direct-entries#building-electrolyte-transport-from-a-material-dataset). For the physics behind the four transport properties and how they are measured experimentally, see [Electrolyte transport properties](/guide/modeling/electrolyte-transport). # Introduction to Pipelines Source: https://docs.ionworks.com/guide/pipelines/overview Chain DirectEntry, Calculation, DataFit, and Validation elements to transform battery parameters and fit models to experimental data. Battery parameterization is built around a simple abstraction: **pipeline elements** that transform input parameters into output parameters, chained together into a **pipeline**. This design provides flexibility to handle any parameterization workflow—from simple calculations to complex data fitting. ## Pipeline Elements The basic building block is a pipeline element. Any pipeline element accepts a set of parameter values (possibly empty) and returns another set of parameter values. The full pipeline is built by calling each element in series to yield the complete parameter set. There are five types of pipeline element: | Type | Description | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | **DirectEntry** | The simplest type—ignores input parameters and returns pre-defined values (e.g., from literature or direct measurements) | | **Calculation** | Computes new parameters based on provided inputs (e.g., calculating maximum particle concentration from capacity, volume fraction, and thickness) | | **DataFit** | Estimates parameters by fitting a model to experimental data | | **ArrayDataFit** | Fits the same model separately at each value of an independent variable (e.g., a separate fit per temperature or per pulse SOC) | | **Validation** | Checks fitted parameters against held-out data | The pipeline element types and built-in calculations listed here are not exhaustive. See the [Pipelines API how-to](/build/parameterize/api) for the full configuration surface. ```mermaid theme={null} flowchart LR A["DirectEntry
known values"] --> B["Calculation
derived parameters"] B --> C["DataFit
fitted parameters"] C --> D["Validation
held-out check"] ``` For example, a capacity pipeline chains an electrode-capacity calculation per electrode with cyclable-lithium and electrode-SOH calculations — each element consuming parameters produced by the elements before it. Each element: 1. Takes input parameters from the parameter dictionary 2. Performs computation 3. Returns output parameters that become available to subsequent elements To assemble and submit a pipeline programmatically, see [Pipelines → Overview](/build/parameterize/overview) in the Documentation tab. ## Naming conventions Use descriptive parameter names with units: `"Electrode capacity [A.h]"` not `"cap"` Be explicit about unit conversions; use SI units internally ## Built-in Calculations Electrode geometry, mass, capacity, cyclable lithium, and microstructure Heat capacity, Arrhenius temperature dependence, and thermal modeling Smooth piecewise functions for SOC and temperature-dependent parameters ## Handling pipeline failures Pipeline elements run sequentially. When an element fails, the pipeline reports an error and any downstream elements stop. The cause is recorded on the element as an `error_code`: | `error_code` | Meaning | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | `SUBMISSION_FAILED` | The element could not be submitted to a worker (for example, transient infrastructure issue). Safe to resubmit. | | `EXECUTION_TIMEOUT` | The element exceeded its execution time limit. Adjust the configuration before retrying. | | `INTERNAL_ERROR` | An unexpected server-side error occurred while running the element. | ### Resubmitting a failed pipeline If the first failed element has `error_code = SUBMISSION_FAILED`, you can resubmit the pipeline. Resubmission resets that element and continues execution from where the pipeline stopped — completed elements are not re-run. Open the pipeline detail page. When the lead failure is a submission error, a **Resubmit** button appears on the failed-element alert at the top of the page. Click it to retry the pipeline in place. Resubmission goes through the generic jobs endpoint, and a pipeline is identified by its job ID (the same ID you pass to `client.pipeline.get(...)`): ```bash theme={null} curl -X POST \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ https://api.ionworks.com/jobs/{pipeline_id}/resubmit ``` The response is the updated pipeline with its elements. The endpoint returns `400` if the first failed element is not in a `SUBMISSION_FAILED` state, and `404` if the pipeline does not exist. See the API Reference for the full schema. For failures with other error codes (timeouts, internal errors, or configuration issues), create a new pipeline with the corrected configuration instead of resubmitting. ## Data Fitting The pipeline abstraction also powers **data fitting**—estimating unknown parameters by comparing model predictions to experimental data. A `DataFit` element wraps a pipeline with an optimization loop: * The optimizer proposes parameter values * The pipeline runs the model with those values * The objective computes how well predictions match data * This repeats until the best-fit parameters are found Cost functions, identifiability, regularization, and other theory. Configure and submit data fits with `ionworks-schema` + `ionworks-api`. ## Pipeline errors When a pipeline element fails, Studio shows the element in a **Failed** state with an error message and a machine-readable **error code**. Hover the status chip in any pipeline list to see the code, and expand the failed element to read the full message. The same fields are returned by the API on failed jobs and pipeline elements: ```json theme={null} { "status": "FAILED", "error": "'Negative electrode loading [A.h.cm-2]' not found. Best matches are [...]", "error_code": "CONFIGURATION_ERROR", "error_detail": { "exception_type": "ParameterNotFoundError" } } ``` ### Error codes Use the `error_code` field to branch on failure type without parsing error strings: | Code | When it's raised | What to do | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | `CONFIGURATION_ERROR` | A pipeline or element is misconfigured — for example, a required parameter is missing, a value is out of range, or a field has the wrong type. Includes `ParameterNotFoundError` from a `ParameterValues` lookup, `UserConfigurationError` raised by pipeline/data-fit parsing, and `ProtocolConfigurationError` raised by the UCP protocol engine (malformed step, unknown end-condition type, missing drive cycle, unresolved `goto` target, initial SOC out of `[0, 100]`, etc.). | Fix the pipeline configuration or input parameters. The `error` message names the offending field. | | `SOLVER_ERROR` | The numerical solver failed to converge (e.g., voltage cut-off violated, integration failure during a simulation). | Check the operating protocol, initial conditions, and parameter values for physical consistency. | | `EXECUTION_TIMEOUT` | The job exceeded its allowed wall-clock time. | Reduce problem size, simplify the model, or split the run. | | `SUBMISSION_FAILED` | The job could not be submitted to the compute backend. | Retry the job. Contact support if it persists. | | `INTERNAL_ERROR` | An unexpected error inside the platform. The raw exception message is intentionally sanitised. | Retry the job. Contact support if it persists. | ### Catching configuration errors in Python Submitting a pipeline with a bad configuration doesn't fail at submission time — the job is accepted, then the offending element fails with `error_code = CONFIGURATION_ERROR` once it runs (see the table above). Waiting for the pipeline with the SDK's `wait_for_completion` raises that failure as an `IonworksError`: ```python theme={null} from ionworks.errors import IonworksError try: client.pipeline.wait_for_completion(pipeline_id) except IonworksError as err: print(f"Pipeline failed: {err}") ``` The exception message carries the underlying failure text — for a missing parameter, the same pybamm lookup message shown above (`"'Negative electrode loading [A.h.cm-2]' not found. Best matches are [...]"`). It does not carry the structured `error_code`; for that, check the failed element's status on the job (the JSON shown above) rather than parsing the exception message. When a UCP protocol can't be simulated as written — for example, a drive cycle referenced by name that wasn't supplied, a `goto` target that doesn't resolve, an end-condition string that doesn't parse, an initial SOC outside `[0, 100]`, or an expression that references an uninitialised variable — the failed pipeline element is tagged with the `CONFIGURATION_ERROR` code and its message is surfaced verbatim through the API. This distinguishes a user-correctable protocol issue (fix the protocol, drive cycles, or inputs and retry) from an unexpected internal failure, which is reported as `INTERNAL_ERROR`. # SimplePipeline Source: https://docs.ionworks.com/guide/pipelines/simple-pipelines Submit single-element pipelines for fire-and-forget data fitting or validation A `SimplePipeline` is a lightweight alternative to the full [Pipeline](/guide/pipelines/overview) when your workflow has **at most one expensive element** — a single `DataFit`, `ArrayDataFit`, or `Validation`. It gives you fire-and-forget execution with a flat result containing `parameter_values`, `cost`, and optional `summary_stats`. ## When to Use | Use SimplePipeline | Use Pipeline | | ------------------------------------------------------ | ------------------------------ | | At most one `DataFit`, `ArrayDataFit`, or `Validation` | Multiple expensive elements | | Fire-and-forget execution | Per-element status tracking | | Flat `parameter_values` result | Cumulative parameter threading | ## How It Works A `SimplePipeline` follows the same three conceptual steps as a full pipeline, just with a single expensive element and a flatter result: 1. **Build a typed config.** `SimplePipeline` inherits everything from `Pipeline`, so you compose the same element types (a `DirectEntry` for known values plus one `DataFit`, `ArrayDataFit`, or `Validation`). It adds client-side validation that rejects configs with more than one expensive element — the error is raised immediately when you build it, so you never wait on a server-side rejection to find out. 2. **Submit fire-and-forget.** The config is sent to Ionworks, which runs it in the background. Because there is at most one expensive element, there is no per-element status to track — you just wait for the whole job to finish. 3. **Read a flat result.** A fit returns `parameter_values` and `cost`; a validation additionally returns `summary_stats`. There is no cumulative parameter threading to unpack — everything is at the top level of the result. ## Running a SimplePipeline The runnable SDK code — building the config with `ionworks_schema`, submitting and polling with `client.simple_pipeline`, and the validation-only variant — lives in the Documentation tab: Build a config, submit with `client.simple_pipeline`, poll for completion, and read the flat result. For the full schema API, see the [ionworks-schema documentation](https://schema.docs.ionworks.com/). # Introduction Source: https://docs.ionworks.com/introduction Overview of Ionworks Studio: a web platform for electrochemical modeling, simulation, and optimization of lithium-ion battery cells Welcome to Ionworks Studio, the web platform for electrochemical modeling and simulation of battery cells. Our goal is to provide a powerful and collaborative environment for battery engineers and researchers to design, analyze, and optimize battery performance. This documentation will guide you through the core features and concepts of Ionworks Studio. ## Getting Started Ready to dive in? Our [Quickstart](/quickstart) guide will walk you through the process of running your first simulation. ## What's New See the latest product updates and announcements. ## Core Concepts To get started, it's helpful to understand the key concepts that form the foundation of Ionworks Studio. Manage your team and collaborate on battery research. Organize your research into high-level initiatives. Define the fundamental properties of your battery cells. ## Data Upload and visualize your experimental battery cycling data. Understand how experimental data is managed in Ionworks Studio. Learn about the required file formats and data structure. Upload experimental data via the Python API. Explore interactive data visualization and filtering. Time series, files, and properties recorded against a cell. Extracted features — capacity, resistance, degradation metrics. Material property datasets used to parameterize models. ## Build Create the building blocks for your simulations, and parameterize them from data. Create electrochemical models to power your simulations. Combine models with parameters to create ready-to-run simulation engines. Fit an equivalent circuit model from cycling data. Chain calculations, fits, and validations into one submitted run. Estimate parameters by fitting a model to experimental data. ## Simulate Run simulations and analyze battery performance. Run focused investigations with simulations and analysis. Run and visualize battery simulations. Create protocols or use built-in experiment templates. Define complex experimental cycles using a universal language. ## Operate Track the physical lab: equipment, channels, and scheduled tests. See what is running on which channel, right now. Request and schedule future lab measurements. ## Assistant Chat with the battery-modelling agent inside Studio — run simulations, fit models, and explore data without leaving the app. ## Optimize Automatically find optimal parameters for your battery designs. Learn how optimization works in Ionworks Studio. Pre-configured templates for common optimization scenarios. Step-by-step guide to running your first optimization. ## Dive Deeper with our Python Libraries For users who want to programmatically interact with Ionworks Studio and develop their own parameterization workflows, we offer powerful Python libraries. Studio, `ionworks-api`, `ionworks-schema`, or `ionworksdata` — start here. A Python client for interacting with the Ionworks API. Pydantic schemas defining the pipeline configuration surface — the typed builder you use to construct jobs before submitting through the Python API. A library for processing experimental data into a common format (BaSyTec, Maccor, Biologic, etc.) and loading processed data for use in other Ionworks software. An open-source battery modeling framework co-developed by the Ionworks team, offering powerful tools for electrochemical modeling and simulation. # AI protocol authoring Source: https://docs.ionworks.com/operate/ai-protocol-authoring Describe a battery test in plain English and get back a validated UCP protocol, ready to run or save as a template **AI protocol authoring** turns a plain-English description of a battery test into a validated [Universal Cycler Protocol](/simulate/universal-cycler-protocol) (UCP). Type what you want to run — for example, `"1C/1C cycling for 200 cycles between 4.2 V and 2.5 V with 10-minute rests"` — click **Generate**, and the generated YAML lands directly in the protocol editor. It sits above the picker, upload, and [Interactive Builder](/simulate/protocol-builder) row on the test-request form as a fourth authoring path. Use it when you know what test you want in words but do not want to hand-write YAML or click through the visual editor. Every protocol the generator returns has already passed the same validation as [`POST /protocols/validate`](/simulate/protocol-api#validating-a-protocol) and contains no unresolved `input["..."]` placeholders, so you can save it as an [experiment template](/simulate/experiment-templates) or attach it to a [planned measurement](/operate/planned-measurements) directly. ## Where you'll find it The prompt appears wherever a protocol is authored in Studio: * **Test scheduler → Request test**, at the top of the **Protocol** section (see [Planned measurements](/operate/planned-measurements)). * **New Protocol**, when creating a saved protocol from the [Protocols](/simulate/protocols) list. * The [Interactive Builder](/simulate/protocol-builder). * The protocol editor in an [optimization objective](/optimize/running-optimization), and when attaching a protocol to an uploaded result. The exact placeholder changes based on context: * **No protocol loaded yet** — `Enter prompt to generate protocol using AI, e.g. "1C/1C cycling for 100 cycles between 4.2 V and 2.5 V"` * **A protocol is already loaded** — `Enter prompt to change protocol using AI, e.g. "make it 200 cycles"` When a protocol is already loaded, the prompt is treated as an *edit* to it (`Update`) rather than a fresh protocol (`Generate`). This is the fastest way to tweak a saved template — say what to change instead of hunting for the step in the editor. ## Writing a good prompt Be specific about the values that change what actually runs on a channel: * **C-rate or current** — `1C`, `C/3`, `10 A` * **Voltage window** — `between 4.2 V and 2.5 V` * **Cycle count**, **rest durations**, **temperatures** * Any **termination** conditions beyond the voltage window (capacity fade, time cap, etc.) Anything you leave vague, the generator will either infer from your [cell specification](/core-concepts/cells) or ask you about — see [Clarifying questions](#clarifying-questions). ### Examples ```text 1C/1C cycling theme={null} 1C/1C cycling for 200 cycles between 4.2 V and 2.5 V with 10-minute rests between charge and discharge. ``` ```text GITT with 20 pulses theme={null} GITT at C/10 with 20 discharge pulses, 30 minutes each, followed by 2 hours of rest. ``` ```text Formation theme={null} Formation: three CC-CV charge/discharge cycles at C/20 between 4.2 V and 3.0 V. ``` ## Cell-specification context If you pass a **cell specification** — the request form on the Test scheduler picks this from the cell you're testing — the generator receives the cell's voltage limits, nominal capacity, and current ratings as facts, so it stops guessing (or asking) about them. This is the single biggest lever on prompt quality: with a cell attached, `"cycle it at 1C"` is enough. Without a cell, spell the voltage window and capacity out in the prompt. ## Clarifying questions If your description leaves something material undecided — the kind of value that would meaningfully change what runs on the channel — the generator asks before writing the protocol. Answer each question and click **Generate protocol**; the answers are added to the exchange and the model tries again. Suggested options appear as chips you can click, or you can type your own answer. The exchange is stateless: the full answer history is re-sent on every retry, so refreshing the page only costs you your typing. You do **not** need to answer questions the generator can figure out from context — attach a cell specification instead of debating cutoff voltages. ## Saved-protocol suggestions Before writing a new protocol, the generator searches your organization's saved protocols for one that already does what you asked. If it finds a match, you'll see a **Saved protocols that already do this** panel with a **Use this** button for each suggestion. Reuse is preferred to regeneration — a saved protocol is already reviewed and named. If the suggestions don't fit, click **Write a new one instead** to skip reuse for that run. ## Explanation and assumptions Once a protocol is written, a short confirmation appears under the prompt: * **Explanation** — one sentence describing what the protocol does. * **Assumed:** — every value the generator picked without being told (typical rest durations, default temperatures, etc.). Read this before running — anything you disagree with is one edit prompt away. The generated YAML lands in the source editor below, and the suggested name lands in the **Protocol name** field. Both are editable before you save. ## Fix with AI When a protocol fails to parse — you uploaded a broken file or edited the YAML by hand — the parse-error alert in the **Request test** form offers a **Fix with AI** button. Like the prompt itself, this action is currently scoped to the test scheduler; the protocol editors elsewhere in Studio show the parse error without the repair button. Clicking it sends the broken protocol and the error message back to the generator with the instruction *"Fix it, changing as little as possible."* Use this to salvage a nearly-correct protocol rather than starting over. ## API Behind the UI is a single endpoint: ```bash cURL theme={null} curl -X POST "https://api.ionworks.com/protocols/generate" \ -H "Authorization: Bearer $IONWORKS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "1C/1C cycling for 200 cycles between 4.2 V and 2.5 V with 10-minute rests", "cell_specification_id": "cell-spec-id" }' ``` ```python Python theme={null} import httpx response = httpx.post( "https://api.ionworks.com/protocols/generate", headers={"Authorization": f"Bearer {api_key}"}, json={ "prompt": ( "1C/1C cycling for 200 cycles between 4.2 V and 2.5 V " "with 10-minute rests" ), "cell_specification_id": "cell-spec-id", }, timeout=120, ) result = response.json() ``` ### Request | Field | Type | Description | | ----------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `prompt` | string, required | Plain-English description of the test to run (up to 4000 characters). | | `current_protocol` | string, optional | Existing protocol as UCP YAML. When supplied the prompt is treated as an *edit* to it rather than a fresh protocol. | | `clarifications` | list of `{question, answer}`, optional | Answers to questions a previous call returned, oldest first. Send the full history each time — the exchange is stateless. | | `cell_specification_id` | string, optional | Cell the test will run on. Its voltage limits, capacity, and rate limits are given to the generator as facts. | | `skip_existing` | bool, default `false` | Skip the saved-protocol search and write a new protocol. Set when the user rejects the suggested matches. | ### Response Exactly one of the three branches is populated: | Field | Populated when | Description | | ----------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `protocol_yaml`, `name`, `explanation`, `assumptions` | The generator wrote a protocol. | Validated UCP YAML, a suggested protocol name, a one-sentence explanation, and the list of values the generator picked without being told. | | `questions` | The generator needs more information. | List of `{question, why, options}` items — ask the user, then re-call with the answers in `clarifications`. | | `matches` | A saved protocol already satisfies the request. | `{template_ids, note}` — surface these as suggestions and let the user pick one, or retry with `skip_existing: true` to write a new one anyway. | Check `questions` and `matches` before reading `protocol_yaml` — both leave it empty. ### Errors * **422** — invalid request (empty prompt, oversized fields, more than 20 clarifications, etc.). These are request-model validation failures, so they arrive as FastAPI's standard validation response, not as a `BAD_REQUEST`. * **502** — the model could not produce a valid protocol. Rephrase the test, or build it with the [Protocol Builder](/simulate/protocol-builder). Protocols returned by the endpoint are guaranteed to pass UCP validation and contain no unresolved `input["..."]` references — the generator retries internally until both hold, so callers do not need to re-validate. ## Limits and tips * The prompt is capped at **4000 characters**; the optional UCP payload at **100,000 characters**; and answers at **20 rounds** per exchange. Beyond that, split the request or pin the values yourself. * The generator uses a frontier model per call. Save the result as an [experiment template](/simulate/experiment-templates) once it's right — reuse is free. * For fully-deterministic authoring (no model in the loop), use the [Protocol Builder](/simulate/protocol-builder) or write UCP YAML directly. ## Next steps Build a UCP protocol visually, step by step. Full reference for the UCP YAML format the generator emits. Save, clone, and configure the generated protocol. Attach the generated protocol to a scheduled lab test. # Channel service Source: https://docs.ionworks.com/operate/channel-service Take a channel out of service with a reason, and read the incident history that records every outage span A channel that is broken, being serviced, or otherwise unusable can be marked **out of service** so it stops appearing as available capacity in the [Lab view](/operate/lab-view). Every transition in and out of that state is recorded as an **incident**, giving you a per-channel downtime history. ## Marking a channel out of service Toggle `out_of_commission` to remove a channel from the pool without deleting it or breaking historical measurements that reference it. Every change to `out_of_commission` also opens or closes a row in the channel's [incident history](#channel-incident-history), so you can look back and see when — and why — a channel was down. Alongside `out_of_commission`, pass three optional fields to describe the outage: | Field | Applies when | Description | | ------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `incident_category` | Taking out of service | Cause of the outage. One of `hardware_failure`, `maintenance`, `calibration`, `decommissioned`, `other`. Defaults to `other`. | | `incident_notes` | Taking out of service | Free-text detail about what happened. | | `resolution_notes` | Returning to service | What was done to bring the channel back. | These three fields **only annotate a transition** in `out_of_commission` — they are ignored on a PATCH that doesn't change the flag. Re-sending the value a channel already has is a no-op, not a new outage. ```python theme={null} # Take a channel out of service and record why. client.channel.update( channel_id, { "out_of_commission": True, "incident_category": "hardware_failure", "incident_notes": "Cell holder cracked; awaiting replacement part", }, ) # Later, bring it back and record the fix. client.channel.update( channel_id, { "out_of_commission": False, "resolution_notes": "Replaced cell holder and recalibrated", }, ) ``` The Lab view shows the channel as `out_of_commission` regardless of any in-flight measurement. `out_of_commission` cannot be set at channel creation — the create endpoint rejects it with a `BAD_REQUEST`. Create the channel first, then PATCH it to take it out of service so the outage is recorded in the incident history. Trying to mark a channel out of commission while a [time-series measurement](/data/measurements) is still open on it returns a `CONFLICT` (HTTP 409) — close the measurement first (patch its `end_time`). Use `incident_category` of `decommissioned` for a permanent retirement rather than a repair-in-progress: the incident stays open indefinitely and is excluded from repair-time statistics. ## Channel incident history Every transition of `out_of_commission` is recorded as an **incident** — one row per outage, opened when the channel goes down and closed when it comes back. Use the history to audit downtime, attribute outages to a cause, or compute per-channel mean time to repair. At most one incident can be open per channel at a time (the current outage). Sending `out_of_commission: true` for a channel that's already down is a no-op — it does not fragment the existing outage into a new row. ### Incident fields | Field | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Unique identifier for the incident. | | `channel_id` | The channel that went out of service. | | `service_event_id` | The instrument-level service this outage belongs to, when it is one. `null` for a channel-local fault — a failed relay is not a cycler event. See [servicing a whole cycler](#servicing-a-whole-cycler). | | `category` | Cause of the outage: `hardware_failure`, `maintenance`, `calibration`, `decommissioned`, or `other`. | | `notes` | Free-text detail captured when the channel went down (from `incident_notes`). | | `service_scheduled_at` | When the booked service is due to happen. `null` on unplanned outages and backfilled rows. | | `service_scheduled_until` | When the booked service is expected to end. `null` when no end was given. A plan, not a guarantee — an outage may run past it, which is what `is_overdue_to_return` reports. | | `started_at` | When the channel went out of service. | | `started_by` | User who took the channel out of service (may be `null` for system actions). | | `resolved_at` | When the channel returned to service. `null` means the outage is still open. | | `resolved_by` | User who returned the channel to service. | | `resolution_notes` | What was done to fix it. | | `is_estimated` | `true` for backfilled rows whose `started_at` is derived from the channel's last-modified timestamp and is only an upper bound. Excluded from repair-time metrics. | ### Reading incident history List a channel's incidents through the HTTP API, most recently started first: ```bash theme={null} GET /channels/{channel_id}/incidents?limit=20&offset=0 ``` ```python theme={null} history = client.channel.list_incidents(channel_id, limit=20) for incident in history: span = incident.resolved_at or "still open" print(f"{incident.started_at} → {span} ({incident.category})") print(f"{len(history)} of {history.total} incidents shown") ``` Pass `limit` (1–100, default 20) and `offset` to page through longer histories; `total` is the full count, not the page size. The channel's current outage is the one with `resolved_at` set to `None` — at most one can be open: ```python theme={null} current = next((i for i in history if i.resolved_at is None), None) if current is not None and current.is_overdue_to_return: print(f"Overdue — was booked back by {current.service_scheduled_until}") ``` `is_overdue_to_return` is derived when you ask rather than stored, because overrunning a booked window is a plan being wrong rather than a record being invalid. A resolved outage is never overdue, however late it was. Channels that already existed before incident history was introduced have a single **backfilled** row with `is_estimated: true`. Its `started_at` is the channel's last-modified time and is only an upper bound on when the outage actually began — treat those rows as "we know it was down by this time" and skip them when computing repair-time statistics. ## Servicing a whole cycler Calibration and preventive maintenance are performed on the *instrument*, so a 40-channel cycler going in for its annual calibration is one event — not 40 unrelated channel outages. Use the cycler service methods rather than marking each channel out of service by hand. Event types are `calibration`, `preventive_maintenance`, `firmware`, `repair`, and `other`. ### Taking a cycler out of service `start_service` opens one event and an incident on **every** channel of the cycler, so the whole instrument reads as down: ```python theme={null} event = client.cycler.start_service( cycler_id, event_type="calibration", scheduled_for="2026-09-01T09:00:00Z", scheduled_until="2026-09-03T17:00:00Z", notes="Annual calibration", ) ``` `scheduled_for` is required — a cycler must not sit out of service with no date attached. `scheduled_until` is optional and says when the instrument is expected back, turning the booking into a span rather than a start instant; when given it must be after `scheduled_for`. It is a plan, not a promise: a visit that runs past it is recorded as overdue, not rejected. Channels already out of service for their own faults keep their more specific incident and stay out when the service completes. ### Recording service as done ```python theme={null} client.cycler.complete_service( cycler_id, event_type="calibration", calibration_interval_days=365, ) ``` `complete_service` takes two paths depending on whether the cycler is currently out of service: | Situation | What happens | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | An open event exists (you called `start_service`) | The event is completed and every channel incident it opened is closed in one write, so the cycler cannot end up half returned to service. | | No open event | A single already-completed event is recorded — the "I calibrated this, log it" case. You do not need to open an event purely so it can be closed. | For a `calibration` event this also advances the cycler's `last_calibrated_at`, and therefore `calibration_due_at`. That is the only supported way the calibration clock moves. ### Reading service history ```python theme={null} history = client.cycler.list_service_events(cycler_id, limit=20) for event in history.items: status = "in progress" if event.performed_at is None else "done" print(event.event_type, status) ``` Events come back newest first. A cycler has at most one open event at a time — the one whose `performed_at` is `None` is the current service. A cycler that is in service reports as unavailable, so scheduling a measurement on it fails when you schedule rather than at the bench. ## Next steps Sites, cyclers, channels, and how measurements link to them. Live channel occupancy for the project. # Equipment Source: https://docs.ionworks.com/operate/equipment Model your lab as sites, cyclers, and channels, and link each cell measurement to the channel it ran on Ionworks Studio can track the physical lab equipment your measurements came from — **sites**, **cyclers**, and **channels** — so you can link a [cell measurement](/data/measurements) directly to the channel it ran on. That gives you an end-to-end link from a data point back to the hardware that produced it, and it is what the [Lab view](/operate/lab-view) renders. ## When to use it Recording equipment is optional — measurements work fine without a `channel_id`. Set up sites, cyclers, and channels when you want to: * Compare data collected across cyclers or labs. * Isolate a suspect channel when a subset of measurements looks off. * Keep long-term provenance for regulated or published data. * See at a glance which channels are free, in use, or out of service. If you just have a single cycler and don't need this level of detail, keep using the free-text [`test_setup`](/data/measurements#common-fields) fields on each measurement instead. ## Equipment hierarchy Test equipment mirrors the cell data hierarchy: ``` Organization └── Site (a lab or facility — org-scoped, shared) └── Cycler (a battery cycler — belongs to one project) └── Channel (a single test channel — inherits its cycler's project) └── linked to a Cell Measurement via channel_id ``` | Resource | Scope | Description | | --------- | ------------------- | -------------------------------------------------------------------------------------------------------------- | | `site` | Organization | A physical lab or facility. Names are unique within an organization (case-insensitive). | | `cycler` | Project | Battery cycling equipment installed at a site. Owned by a single project, set at creation. | | `channel` | Project (inherited) | A single test channel on a cycler. Inherits its parent cycler's project — you never set `project_id` yourself. | Only cell measurements from the **same project** as the channel can be linked to it. Deleting a site cascades to its cyclers, and deleting a cycler cascades to its channels. ### Sites A **site** is a lab or facility that owns cyclers. Sites are **organization-scoped** — a physical lab is shared infrastructure, not tied to a single project. | Field | Description | | ---------- | -------------------------------------------------- | | `name` | Name of the site (required, unique within the org) | | `location` | Physical location (e.g. address or lab name) | | `notes` | Free-text notes | ### Cyclers A **cycler** is a piece of cycling equipment installed at exactly one site. Cyclers are **project-scoped**: each cycler is owned by one [project](/core-concepts/projects-studies), passed at create time. Cycler names are unique within a project (case-insensitive), so two different projects can each have a cycler named `"Maccor-1"`. The owning project is fixed at creation. If the same physical machine is used across two projects, create one cycler row per project. | Field | Description | | -------------- | -------------------------------------------------------- | | `name` | Name of the cycler (required, unique within the project) | | `project_id` | Project that owns the cycler (required) | | `manufacturer` | e.g. `"Arbin"`, `"Maccor"`, `"Biologic"` | | `model` | e.g. `"S4000"` | | `notes` | Free-text notes | ### Channels A **channel** is a physical channel on a cycler. A channel belongs to exactly one cycler and inherits that cycler's project. Channel names are unique within a cycler (case-insensitive), so two different cyclers can each have a channel named `"CH1"`. | Field | Description | | ------- | -------------------------------------------------------- | | `name` | Name of the channel (required, unique within the cycler) | | `notes` | Free-text notes | Channels also carry optional electrical ratings and an `out_of_commission` flag — see [Channel ratings](#channel-ratings) below. ## Building the equipment tree Create the site, cycler, and channels once per lab. In steady state, you'll only touch channels — flipping `out_of_commission`, adjusting ratings, or linking measurements to them. ```python theme={null} from ionworks import Ionworks client = Ionworks(project_id="your-project-id") # 1. Create (or fetch) the site — org-scoped, shared across projects. site = client.site.create_or_get({ "name": "Main lab", "location": "Building 3, Room 210", }) # 2. Create a cycler under the site. project_id is required — the cycler # is owned by that project. cycler = client.cycler.create_or_get( site.id, { "name": "Cycler-01", "manufacturer": "Arbin", "model": "LBT-5V-6A", "project_id": client.project_id, }, ) # 3. Create channels on the cycler with optional electrical ratings. for i in range(1, 9): client.channel.create_or_get( cycler.id, { "name": f"Ch-{i:02d}", "max_amps": 6.0, "min_volts": 0.0, "max_volts": 5.0, }, ) ``` Names are unique per org (sites) or per project (cyclers) or per cycler (channels), case-insensitively — but `create_or_get` only resolves a conflict back to the existing record when the name matches exactly. Calling it again with a differently-cased name that already exists (e.g. `"boston lab"` when `"Boston Lab"` exists) raises `ValueError` instead of returning the existing record. Reuse the exact name you created it with. Update and delete work the same way for all three resources: ```python theme={null} # Update (partial). Passing a new site_id moves a cycler to another site in # the same organization; the owning project cannot change. Passing a new # cycler_id reparents a channel — but only to another cycler in the same # project. client.cycler.update(cycler.id, {"model": "S4000M"}) # Delete — cascades down the hierarchy (site -> cyclers -> channels). # Deleting a channel sets channel_id back to NULL on any measurements that # referenced it; the measurements themselves are not deleted. client.cycler.delete(cycler.id) ``` | Delete | Effect | | ------- | ----------------------------------------------------------------------------------------------- | | Site | Deletes all its cyclers and their channels | | Cycler | Deletes all its channels | | Channel | Sets `channel_id = NULL` on any measurements that referenced it; the measurements are preserved | ## Linking a measurement to a channel Every [cell measurement](/data/measurements) has an optional, nullable `channel_id` recording the physical channel a test ran on. Set it when creating a measurement or attach it later with an update. The channel must belong to the **same project** as the measurement (a measurement inherits its project from its cell instance). Pointing at a channel in another project raises an error (HTTP 400). The link is loose on purpose: deleting the channel later sets `channel_id` back to `NULL` and leaves the measurement intact. ```python theme={null} channels = client.channel.list(cycler.id) ch01 = next(c for c in channels if c.name == "Ch-01") bundle = client.cell_measurement.create( cell_instance.id, { "measurement": { "name": "Formation Cycle 1", "channel_id": ch01.id, }, "time_series": time_series, }, ) # Attach or move the channel on an existing measurement client.cell_measurement.update(bundle.id, {"channel_id": ch01.id}) # Detach without deleting the measurement client.cell_measurement.update(bundle.id, {"channel_id": None}) ``` While the test is running, leave the measurement's `end_time` unset and continue uploading fresh data — the channel reads as `occupied`. When the test finishes, patch the measurement with an `end_time` and the channel returns to `free`. ### Showing when a channel will free up Set `estimated_end_time` on the measurement to advertise when the running test is expected to finish. The Lab view surfaces it on the channel card while the channel is `occupied` or `stale`, so you can plan the next test without opening the measurement. ```python theme={null} from datetime import datetime, timedelta, timezone client.cell_measurement.update( measurement_id, { "estimated_end_time": ( datetime.now(timezone.utc) + timedelta(hours=18) ).isoformat(), }, ) ``` `estimated_end_time` is informational only — it doesn't change the channel's occupancy state or the 48-hour staleness window. A partial update only sets the fields you include, so patching just `end_time` leaves the stored estimate in place — explicitly set `estimated_end_time` to `None` in that same update to clear it. ## Channel ratings Channels have optional electrical ratings you can set to describe what the hardware can do. The Lab UI uses them to filter channels when picking one for a new test. | Field | Type | Description | | ------------------- | --------------- | ---------------------------------------------------------------------------------------------------- | | `max_amps` | `float \| null` | Maximum rated current in A. `null` means unrated. | | `min_volts` | `float \| null` | Minimum rated voltage in V. `null` means unrated. | | `max_volts` | `float \| null` | Maximum rated voltage in V. `null` means unrated. | | `out_of_commission` | `bool` | `true` marks the channel out of service. Overrides the derived occupancy state. Defaults to `false`. | Ratings are set via the API or SDK, not the UI. ## Filtering, pagination, and resolving by name Site, cycler, and channel list endpoints share the same filtering shape used across the rest of the API — case-insensitive substring match on `name`, exact match with `name_exact`, ISO datetime range filters (`created_after` / `updated_before` / ...), and `order_by` / `order` for sort. ```python theme={null} # All cyclers in a site owned by the current project cyclers = client.cycler.list(site.id, project_id=client.project_id) # One-page walk of every channel on a cycler channels = client.channel.list(cycler.id, limit=100) print(channels.total) ``` For sites and cyclers, `client.site.detail(site_id)` and `client.cycler.detail(cycler_id)` return the resource together with all of its children in a single response, walking pagination for you. Use `name_exact` for a case-sensitive server-side match — it's the easiest way to walk from a human-readable site name down to a channel id: ```python theme={null} site = client.site.list(name_exact="Boston Lab")[0] cycler = client.cycler.list(site.id, name_exact="Cycler-01")[0] channel = client.channel.list(cycler.id, name_exact="Ch-01")[0] print(site.id, cycler.id, channel.id) ``` Each `list()` returns a `PaginatedList` even when you use `name_exact`, so handle the empty case if the name may not exist. ## Next steps Watch live occupancy, inspect a channel, and see what is running now. Take a channel out of service and read its incident history. # Lab view Source: https://docs.ionworks.com/operate/lab-view A project-scoped dashboard of channel occupancy — see what is running, inspect a channel, watch a run, and query occupancy from the SDK The **Lab view** is a project-scoped dashboard over the [equipment tree](/operate/equipment) that shows every channel in your test equipment and whether it is currently in use. Use it to answer "what is running right now, and when does a channel free up?". ## Channel occupancy The Lab view derives each channel's state from the measurements linked to it — there's no live telemetry. A channel is in one of four states: | State | Meaning | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `free` | No linked measurement, or every linked measurement has an `end_time` set. The channel is available for a new test. | | `occupied` | A linked measurement has no `end_time` and was updated within the staleness window. A cell is on test now. | | `stale` | A linked measurement has no `end_time` but hasn't been updated within the staleness window. Likely a stopped or silently failed test, or one someone forgot to mark complete. | | `out_of_commission` | The channel is deliberately out of service (broken or under maintenance). Overrides the derived state above. | The staleness window is fixed at **48 hours**. This is comfortably larger than a typical daily cycler upload cadence, so a channel only flips to `stale` after it has skipped roughly two expected updates — the real "stopped or forgotten" signal, not a briefly-late update. To free a `stale` channel, set an `end_time` on the linked measurement (mark the test complete) or delete the measurement. ## Viewing channels as a table The Lab view can be switched between the default card layout (channels grouped under their cycler) and a **table view** that renders **one row per channel** across every cycler in the project. Use the layout toggle at the top of the Lab view to switch modes. The choice is not saved — the view resets to cards on refresh or navigation. Use the table view when you want to: * Scan every channel in the project on a single flat list rather than a per-cycler grid. * Filter or compare channels across cyclers — for example, show all `stale` channels or compare their electrical ratings. * Copy a channel-by-channel snapshot of what the lab is doing right now. Each row shows the same information the channel card does — site, cycler, channel name, occupancy state, electrical ratings, the active measurement (if any) and its `estimated_end_time`, and the `out_of_commission` flag — so nothing is hidden by switching layouts. Clicking a row opens the same [channel page](#inspecting-a-channel) as clicking a card, and the same `channel:read` permission gates access. The table reads from the same aggregation `client.lab.status()` exposes, so for the equivalent flat, one-row-per-channel view from Python, flatten the tree yourself: ```python theme={null} status = client.lab.status(client.project_id) for site in status.sites: for cycler in site.cyclers: for channel in cycler.channels: print(site.name, cycler.name, channel.name, channel.state) ``` ## Inspecting a channel Click a channel in the Lab view to open its **channel page** — a dedicated view of everything that's on (and has ever been on) that piece of hardware. The page pulls together the same data other parts of Ionworks Studio already show, so you don't need to jump between measurements to see what a channel is doing. The channel page shows: * The channel's current state (`free`, `occupied`, `stale`, or `out_of_commission`) and its electrical ratings. * The **active measurement**, if any — the cell, protocol, start time, and estimated end time — with a **Mark complete** action that sets `end_time` on the measurement and frees the channel. * Recent data for the active measurement, split into two tabs: * **Time series** — a trailing window of raw signals (voltage by default; add current, temperature, or any other column via the plot's own selectors). The x-axis is a calendar-date axis anchored to the measurement's `start_time`, so the plot reads in real dates rather than elapsed seconds. * **Cycles** — per-cycle summary metrics across the whole measurement. * The **history** of the 100 most recent measurements that have run on this channel, ordered by start time, with links back to each measurement's own detail page. The time-series window is user-controlled — set the number of days (default 7) and click **Refresh** to re-fetch. Nothing polls on its own, so the plot only changes when you ask for it; the "as of" stamp shows when the plotted snapshot was fetched. Only channels with an `occupied` or `stale` active measurement have data to plot — a `free` or `out_of_commission` channel shows the state badge and history but no chart. Marking a measurement complete from the channel page is equivalent to patching its `end_time` — the same operation you can do from the SDK — and moves the channel back to `free` immediately: ```python theme={null} from datetime import datetime, timezone client.cell_measurement.update( measurement_id, {"end_time": datetime.now(timezone.utc).isoformat()}, ) ``` Access to the channel page respects the `channel:read` permission — users without it see the equipment tree in the Lab view but can't drill into a channel. ## Querying occupancy from the SDK `client.lab` wraps the same aggregation the Lab view renders from and answers the common "what's my lab doing right now?" questions from Python. It's read-only — use `client.site`, `client.cycler`, and `client.channel` to mutate equipment. | Method | Answers | | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `client.lab.status(project_id)` | The full sites → cyclers → channels tree with each channel's derived state and on-test measurement, plus project-level occupancy counts. | | `client.lab.utilization(project_id)` | Headline busy percent (`occupied` + `stale`, out-of-commission included in the denominator) plus the raw counts. Matches the number on the Lab wall. | | `client.lab.free_channels(project_id, *, min_amps, min_volts, max_volts)` | Free channels, optionally filtered by required electrical ratings. Answers "where can I run this cell?". | | `client.lab.stale_channels(project_id)` | Channels whose linked test has gone quiet — likely stopped, silently failed, or forgotten tests tying up equipment. | | `client.lab.on_channel(project_id, channel_id)` | The measurement currently on test on a channel, or `None` when it's free, out of commission, or not in the project. | Only `status()` hits the network. The other methods are pure client-side views over that payload — each fetches its own snapshot by default, so a single call is always internally consistent. ```python theme={null} util = client.lab.utilization(project_id) print(f"{util.percent}% busy — {util.occupied}/{util.total} channels running") # 4 A cell needing a 0–5 V window candidates = client.lab.free_channels( project_id, min_amps=4.0, min_volts=0.0, max_volts=5.0, ) for fc in candidates: print(f"{fc.site_name} / {fc.cycler_name} / {fc.channel.name}") for fc in client.lab.stale_channels(project_id): m = fc.channel.measurement print(f"Stale: {fc.cycler_name}/{fc.channel.name} — last update {m.updated_at}") ``` Two separate calls fetch two snapshots and can disagree if equipment changes in between. When you need several views of the **same** instant — for example utilization alongside the free-channel list — fetch one snapshot and pass it in via the `status` keyword: ```python theme={null} snapshot = client.lab.status(project_id) util = client.lab.utilization(project_id, status=snapshot) free = client.lab.free_channels(project_id, status=snapshot) # same instant ``` Channels missing a rating are excluded from `free_channels` when that rating is used as a filter — an unrated channel can't be shown to satisfy the requirement. Drop the filter to include unrated channels. ## Watching a channel **Watch** a channel to follow the test that's currently running on it. Engineers typically watch the handful of channels running their own cells; from the Lab wall you can then flip a **Only watched** filter to hide everything else and drill straight into the runs you care about. A watch is tied to the specific [cell measurement](/data/measurements) currently on the channel, not to the channel itself — so the moment that measurement finishes (an `end_time` is set), the watch **clears itself automatically**. Starting a new test on the same channel does not silently re-enter it into your watchlist; opt in again when a new run begins. Set watches from the channel page or the Lab wall — a **Watch** toggle appears next to any channel that has a live `occupied` or `stale` measurement (a `free` or `out_of_commission` channel has nothing to watch). Watches are per-user: each teammate curates their own watchlist without affecting anyone else. ## Scheduling invariants The server enforces a small set of scheduling invariants so the Lab view and your equipment records stay consistent. Requests that violate time-ordering or type rules are rejected with HTTP 400, while channel scheduling conflicts (overlap, out-of-commission) return HTTP 409. Nothing is written and the channel state is unchanged. | Invariant | When it triggers | Status | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | One active measurement per channel | You try to link a measurement (create or update `channel_id`) to a channel that already has another linked measurement without an `end_time`. | 409 | | No scheduling on `out_of_commission` channels | You try to set `channel_id` on a measurement to a channel whose `out_of_commission` is `true`. Existing links on channels flipped out of service later are preserved. | 409 | | Only `time_series` measurements can reference a channel | A `properties` or `file` measurement (no physical channel occupancy) supplies a `channel_id`. | 400 | | `end_time` must not precede `start_time` | You patch a measurement so its `end_time` falls before its `start_time`. This check predates channel scheduling and applies to every measurement, linked or not. | 400 | The overlap check counts only measurements without an `end_time` — a completed test never blocks a new one. To reschedule a busy channel, first close the running measurement (patch its `end_time`) or move it to a different channel, then link the new measurement. ```python theme={null} from datetime import datetime, timezone # Free the channel before starting the next test on it. client.cell_measurement.update( previous_measurement_id, {"end_time": datetime.now(timezone.utc).isoformat()}, ) client.cell_measurement.update(new_measurement_id, {"channel_id": ch01.id}) ``` If you need to bring a channel back into service, clear `out_of_commission` before assigning a new measurement: ```python theme={null} client.channel.update(channel_id, {"out_of_commission": False}) client.cell_measurement.update(measurement_id, {"channel_id": channel_id}) ``` ## Next steps Mark a channel out of service and review its outage history. Request a test, schedule it onto a channel, and link the real run back. # Operate overview Source: https://docs.ionworks.com/operate/overview Run the physical lab: model your equipment, watch channel occupancy, service channels, and plan the tests you want run **Operate** covers running the physical lab — the equipment your data comes from, what is on it right now, and what you want run next. It sits upstream of [Data](/data/overview): Operate is where a test is requested and executed, and Data is where the resulting measurement is uploaded, read, and analysed. Model your lab as sites, cyclers, and channels, and link each measurement to the channel it ran on. A project-scoped dashboard of channel occupancy — what is running, and when a channel frees up. Take a channel out of service with a reason and read its incident history. Request a test, schedule it onto a channel, and link the real run back to the plan. Describe a test in plain English on the request form and get back a validated UCP protocol. ## Where to start If you are setting the lab up for the first time, build the [equipment tree](/operate/equipment) first — everything else keys off channels. Once channels exist, the [Lab view](/operate/lab-view) becomes useful, and the [test scheduler](/operate/planned-measurements) can place requests onto real hardware. Recording equipment is optional. Measurements work fine without a `channel_id`, so if you have a single cycler you can skip Operate entirely and use the free-text [`test_setup`](/data/measurements#common-fields) fields instead. # Planned measurements Source: https://docs.ionworks.com/operate/planned-measurements Request future lab tests, schedule them onto a channel and time window, and link the real measurement back to the plan when it starts A **planned measurement** is a project-scoped record of a test you intend to run. A *requester* creates it as `requested` (with an estimated duration but no channel); a *scheduler* later assigns a channel and a `[planned_start_time, planned_end_time)` window, moving it to `scheduled`. Planned measurements never create or mutate a real [cell measurement](/data/measurements). When the real test starts, the actual `cell_measurement` links back to the plan and moves it to `in_progress` — the planned row stays as the request-and-schedule audit trail. ## When to use it Planned measurements are optional — you can still create measurements directly and set `channel_id` at run time as described in the [Lab view](/operate/lab-view). Use planned measurements when you want to: * Track a **backlog** of tests requested against a project or a specific cell specification. * **Reserve future channel time** so two schedulers don't book the same channel and window. * Separate the *request* (from a scientist or PM) from the *scheduling* decision (from a lab operator). * Keep an audit trail of who requested and scheduled each test. If you just have a single cycler and start tests ad hoc, keep linking measurements to channels directly and skip planned measurements. ## Lifecycle ``` requested ──► scheduled ──► in_progress ──► completed │ │ └── cancelled ┘ ``` | Status | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------- | | `requested` | A future test with no channel yet. Requires `estimated_duration_seconds`. This is what a *requester* creates. | | `scheduled` | A *scheduler* has assigned a `channel_id` and a `[planned_start_time, planned_end_time)` reservation. Requires all three. | | `in_progress` | Set automatically once the real `cell_measurement` starts and links to the plan. Carries `started_measurement_id`. | | `completed` | Set once the linked real measurement finishes. | | `cancelled` | Plan abandoned before completion. The row is kept as an audit trail. | A scheduler can either pick the channel and times by hand, or ask for an [auto-schedule proposal](#auto-scheduling-a-batch-of-requests) that fills the earliest free windows for a batch of requests. Auto-scheduling never moves reservations that already exist. Reserving future channel time on a `scheduled` plan does **not** make the [Lab view](/operate/lab-view) show the channel as `occupied` — only a running real measurement does that. A plan reserves the calendar; a `cell_measurement` reserves the current moment. ## Fields | Field | Description | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Human-readable name (required, unique within the project). | | `status` | Defaults to `"requested"`. Create as `requested` or `scheduled`. | | `protocol_id` | The [protocol](/simulate/protocols) (experiment template) this test will run. Required — every planned measurement must name a protocol, and it must be a fully-specified, valid protocol (see [Protocol validation](#protocol-validation)). | | `cell_specification_id` | The [cell specification](/core-concepts/cells) this test will run on. Required. | | `estimated_duration_seconds` | Required for `requested` rows. | | `channel_id`, `planned_start_time`, `planned_end_time` | Required for `scheduled` rows. `planned_end_time` must be after `planned_start_time`. | | `cell_instance_id` | The concrete cell instance the test will run on. Usually set by the scheduler. If both `cell_specification_id` and `cell_instance_id` are set, the instance must belong to the specified spec. | | `setup_duration_seconds`, `teardown_duration_seconds` | Operator setup and teardown time, in seconds. Default `0`. | | `program_id` | Optional program this test belongs to (e.g. Formation, Cycling) — see [Programs](#programs). Must reference a program in the same organization. Copied onto the real measurement when it links back to the plan. | | `test_setup`, `notes` | Optional free-text planning metadata. | | `started_measurement_id` | Set automatically when a real measurement links back to the plan. | Names are unique per project (case-insensitive). Creating a plan with a duplicate name raises `IonworksError` with `error_code == "CONFLICT"` (HTTP 409\). Use `create_or_get` to make creation idempotent. ## Requesting a measurement Create a `requested` plan with no channel; supply the [protocol](/simulate/protocols) to run, the cell specification you want tested, and an estimated duration. The scheduler picks the concrete cell instance and channel later. ```python theme={null} from ionworks import Ionworks client = Ionworks(project_id="your-project-id") planned = client.planned_measurement.create({ "name": "Formation cycling", "protocol_id": "experiment-template-id", "cell_specification_id": "cell-spec-id", "estimated_duration_seconds": 7200, "notes": "Run at 25 C", }) # planned.status == "requested", planned.channel_id is None ``` Use `create_or_get` to make the request idempotent by name: ```python theme={null} planned = client.planned_measurement.create_or_get({ "name": "Formation cycling", "protocol_id": "experiment-template-id", "cell_specification_id": "cell-spec-id", "estimated_duration_seconds": 7200, }) ``` The **Protocol** section of the **Request test** form leads with a plain-English prompt: describe the test (e.g. `"1C/1C cycling for 200 cycles between 4.2 V and 2.5 V"`), click **Generate**, and you get a validated UCP protocol back without writing YAML or clicking through the visual builder. The attached cell specification is used as context, so you rarely need to spell out voltage limits. See [AI protocol authoring](/operate/ai-protocol-authoring) for details. ### Protocol validation `protocol_id` must reference an [experiment template](/simulate/experiment-templates) in your organization, and the underlying protocol must be ready to run against a real cell — a planned measurement pins the exact test the lab will execute, so half-configured protocols are rejected at request time rather than surfacing as a failed run later. Both `create` and `update` reject a plan with `IonworksError` (HTTP 400) when the `protocol_id`: * **Does not exist in your organization.** The error is `protocol_id '…' does not reference a protocol in this organization.` * **Still has unresolved inputs.** Protocols authored with `input["…"]` placeholders (see [Parameterized inputs](/simulate/protocols#parameterized-inputs)) must have every input resolved to a concrete value before they can back a planned measurement. The error lists the missing input names: `protocol_id '…' references a protocol with unresolved inputs (C-rate, Temperature [°C]). Planned measurements require a fully-specified protocol — set all input values first.` * **Fails UCP validation.** Structurally invalid protocols (bad step definitions, inconsistent terminations, and so on — the same checks `POST /protocols/validate` runs) are rejected with the underlying validation error attached. Fix the protocol first, then retry the request. If the protocol you want to run isn't fully specified yet, clone it and pin the inputs on the copy — see [Protocols](/simulate/protocols) for how to author and configure protocol inputs. ## Scheduling onto a channel Assign a channel and a time window with `schedule` — a convenience wrapper that moves a `requested` plan to `scheduled`. ```python theme={null} planned = client.planned_measurement.schedule( "planned-measurement-id", channel_id="channel-id", planned_start_time="2026-07-21T09:00:00+00:00", planned_end_time="2026-07-21T11:00:00+00:00", ) # planned.status == "scheduled" ``` You can also create a plan that starts already `scheduled` in one call: ```python theme={null} planned = client.planned_measurement.create({ "name": "RPT", "status": "scheduled", "protocol_id": "experiment-template-id", "cell_specification_id": "cell-spec-id", "channel_id": "channel-id", "planned_start_time": "2026-07-21T09:00:00+00:00", "planned_end_time": "2026-07-21T11:00:00+00:00", }) ``` ### Picking a channel in the UI The Schedule dialog's channel picker shows each channel's live availability and electrical ratings alongside its name, so you don't need to jump to the [Lab view](/operate/lab-view) to judge fit: * **Primary line** — `cycler / channel`. * **Ratings** — max current and voltage window (e.g. `40 A · 0–5 V`), pulled from the channel's [ratings](/operate/equipment#channel-ratings). Unrated fields are omitted. * **State** — `free`, `occupied`, or `stale`, derived from the same [channel occupancy](/operate/lab-view#channel-occupancy) rules as the Lab wall. Out-of-commission channels are listed but disabled. All non-out-of-commission channels remain selectable — occupied and stale channels are shown so you can still reserve future windows on them. The picker does not filter or sort by "fit" against the protocol; the ratings are informational. ### Scheduling conflicts Scheduling is rejected when the window would collide with something already on the channel. Pick a different channel or window and retry. | Rejection | When it triggers | Status | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | | Overlapping scheduled plan | The window overlaps another non-cancelled `scheduled` plan on the same channel. | 409 | | Overlapping active measurement | The window overlaps an active `cell_measurement` on the same channel. Active measurements with no `estimated_end_time` block scheduling by default. | 409 | | Channel out of commission | The target channel has `out_of_commission = true`. Only new `scheduled` reservations are rejected — existing plans keep transitioning normally. | 400 | | Missing required fields | A `scheduled` row without `channel_id`, `planned_start_time`, or `planned_end_time`, or `planned_end_time <= planned_start_time`. | 400 | | Invalid `protocol_id` | The protocol doesn't exist in the organization, still has unresolved `input["…"]` placeholders, or fails UCP validation. See [Protocol validation](#protocol-validation). | 400 | Cancelling or completing an existing plan is always allowed, even after the channel goes out of commission. ## Auto-scheduling a batch of requests Instead of picking a channel and window for each request by hand, you can ask for a **proposal**: the earliest free window on an eligible channel for each of a selected, priority-ordered set of `requested` plans. The proposal is a suggestion only — nothing is reserved until you apply it. ```python theme={null} proposal = client.planned_measurement.auto_schedule_proposal( ["formation-plan-id", "rpt-plan-id"], # priority order ) for assignment in proposal.assignments: if assignment.is_scheduled: print( f"{assignment.planned_measurement_name}: " f"{assignment.cycler_name} / {assignment.channel_name} " f"at {assignment.planned_start_time}" ) else: print(f"{assignment.planned_measurement_name}: {assignment.unscheduled_reason}") ``` How placement works: * **Order is priority.** Requests are placed in the order you list them, each taking the earliest window still free after the ones before it. * **The reserved window covers the whole occupancy** — `setup_duration_seconds` \+ `estimated_duration_seconds` + `teardown_duration_seconds`. * **Existing reservations are never moved.** `scheduled` plans and active measurements on a channel are treated as fixed blockers. * **Out-of-commission channels are skipped.** All other channels in the project are candidates; ratings are *not* matched against the protocol, so check fit yourself the same way you would when [picking a channel](#picking-a-channel-in-the-ui). * **The search is unbounded in time.** A request gets the earliest window that genuinely fits, however far in the future that falls. Check `planned_start_time` if the reservation needs to land inside a particular window. By default the search starts from now, which reserves channel time immediately. Pass `start_after` when an operator can't begin right away — otherwise you book a slot nobody can claim. Times in the past are clamped to now. ```python theme={null} from datetime import UTC, datetime, timedelta proposal = client.planned_measurement.auto_schedule_proposal( ["formation-plan-id", "rpt-plan-id"], start_after=(datetime.now(UTC) + timedelta(hours=2)).isoformat(), ) ``` ### Unscheduled assignments An assignment comes back without a channel or window when no free window can be computed at all. It carries an `unscheduled_reason`, and `is_scheduled` is false: | Reason | What to do | | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | The project has no in-service channels. | Add a channel, or bring one back from [out of commission](/operate/equipment). | | Every in-service channel is running a measurement with no estimated end time. | Set `estimated_end_time` on the running measurements, or schedule these requests by hand. | ### Applying a proposal Send back only the complete assignments. Applying is all-or-nothing: if any one is rejected, every write already made by the request is rolled back. ```python theme={null} assignments = [a for a in proposal.assignments if a.is_scheduled] scheduled = client.planned_measurement.apply_auto_schedule_proposal(assignments) ``` Each assignment is re-checked against the request as it stands now, not as it stood when the proposal was generated. A stale proposal raises `IonworksError` with `error_code == "SCHEDULE_STALE"` — generate a fresh one and review it again. | Rejection | When it triggers | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `SCHEDULE_STALE` (409) | The request was edited, scheduled, or cancelled since the proposal was generated. | | Incomplete assignment (400) | An assignment is missing a channel or window, or still carries an `unscheduled_reason`. | | Start time in the past (400) | You reviewed the proposal for long enough that its earliest window has passed. | | Duration mismatch (400) | The window no longer matches the request's setup + run + teardown duration. | | Overlapping assignments (400) | Two assignments in the same call overlap on one channel, or the window collides with something already on the channel. | ## Listing and filtering `list` is project-scoped and returns a `PaginatedList[PlannedMeasurement]`. Filter by lifecycle status, channel, or name. ```python theme={null} # The scheduler's queue: everything still requested requested = client.planned_measurement.list(status="requested") # The lab's calendar: scheduled plans, earliest first scheduled = client.planned_measurement.list( status="scheduled", order_by="planned_start_time", order="asc", ) # Everything on a specific channel on_channel = client.planned_measurement.list(channel_id="channel-id") # Paginate page = client.planned_measurement.list(limit=25, offset=0) print(page.count, page.total) # Get one by id planned = client.planned_measurement.get("planned-measurement-id") ``` ## Updating, cancelling, and deleting ```python theme={null} # Partial update — edit notes or estimated duration in place client.planned_measurement.update( "planned-measurement-id", {"notes": "Revised setup"} ) # Cancel — keeps the row as an audit trail client.planned_measurement.cancel("planned-measurement-id") # Delete — removes the row entirely client.planned_measurement.delete("planned-measurement-id") ``` To reschedule a plan, call `schedule` again with a new channel or window (or `update` the individual fields). The server re-runs the overlap and out-of-commission checks against the new values. ## Test scheduler row actions The **Test scheduler** page (per project, under **Lab → Test scheduler**) lists every plan in the backlog. Each row's overflow menu offers the actions below. Which ones show up depends on the plan's current status — the UI hides an action rather than letting you click it into an error. | Action | Available when status is | What it does | | --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | Edit | `requested`, `scheduled` | Change the request's fields (name, cell spec, protocol, duration). | | Copy | any status | Opens the request form prefilled from this row. Nothing is created until you submit; the copy always starts fresh in the backlog as `requested`. | | Schedule / Reschedule | `requested`, `scheduled` | Assign a channel and window, or change an existing assignment. | | Unschedule | `scheduled` | Release the channel and window, returning the test to the backlog as `requested`. | | Reopen | `cancelled` | Bring a cancelled plan back to the backlog as `requested`. | | Cancel | `requested`, `scheduled` | Move the plan to the terminal `cancelled` state. Reversible via **Reopen**. | | Delete | `cancelled` | Remove the row entirely. Irreversible. | **Delete** is deliberately gated behind **Cancel**: you cannot delete a live request, a booked channel, or the record of a test that ran. Cancel first, then delete if you want the row gone. **Copy** is the fastest way to queue up several near-identical tests (same cell spec and protocol, different notes or setup). It uses the same request form as the **Request test** button, so the usual validation applies. The new plan gets its own name and its own audit fields — the original is not touched. ## Linking a real measurement to a plan You do not transition a plan to `in_progress` by hand. When the real run starts, create the `cell_measurement` with `planned_measurement_id` set — the backend links it and moves the plan to `in_progress` atomically. ```python theme={null} measurement = client.cell_measurement.create( cell_instance_id, { "measurement": { "name": "Formation run", "channel_id": "channel-id", "planned_measurement_id": "planned-measurement-id", }, "time_series": time_series, }, ) ``` The link is rejected with HTTP 400 unless **all** of these hold: * The plan is `scheduled`. * The measurement is a `time_series` measurement and carries a `channel_id` (a `planned_measurement_id` with no `channel_id` is rejected). * The plan and measurement are in the same project and organization. * The measurement's `channel_id` equals the plan's `channel_id`. * If the plan has a `cell_instance_id`, the measurement must run on that same cell instance. Two concurrent measurements can never both claim the same plan — the link is an atomic compare-and-set on `status = scheduled`, so the loser is left unlinked (and logged as a warning). After linking, channel occupancy on the [Lab view](/operate/lab-view) is driven by the running `cell_measurement`. The planned row remains as the audit trail. ### Auto-watching the requester When a `scheduled` plan links to a real `cell_measurement` and transitions to `in_progress`, the requester (`requested_by` on the plan) is automatically added as a watcher on the started measurement. Requesters see the run appear under **My Channels** in the [Lab view](/operate/lab-view) on their next lab status refresh — no manual **Watch** click required. Behavior: * Fires on both link paths: creating a `cell_measurement` with `planned_measurement_id` set, and patching a plan to `in_progress` with a `started_measurement_id`. * Idempotent — re-linking or re-transitioning does not create duplicate watches, and already-watching users stay watching. * Only `requested_by` is auto-watched. `scheduled_by` and lab operators are not; they can still watch manually from the channel or measurement pages. * Best-effort — a watch failure never fails the measurement create or plan update. The link and status transition still succeed. Requesters who don't want to be notified can unwatch the measurement from its detail page after it starts. ## Programs A **program** is an organization-scoped catalog entry — a short, reusable name for a class of lab test such as `Formation`, `Cycling`, or `RPT`. Setting `program_id` on a plan records which category the test belongs to, and the value is copied onto the real measurement when it links back to the plan. | Field | Description | | ------------ | ---------------------------------------------------------------------------------------------- | | `program_id` | The Program to tag this plan with. Optional. Must belong to the same organization as the plan. | Programs are not free-text tags. The catalog is managed from **Organization settings → Programs**, and the request and schedule forms pick from that list, so the same category is spelled the same way by everyone. Names are unique within the organization (case-insensitive). Once tagged, the program is visible on the plan and in the test scheduler's planning table, so a lab operator sees at a glance which category a request belongs to. ```python theme={null} # Tag a plan at creation time planned = client.planned_measurement.create({ "name": "Formation cycling", "estimated_duration_seconds": 7200, "program_id": "program-id", }) # Or attach a program to an existing plan client.planned_measurement.update( "planned-measurement-id", {"program_id": "program-id"}, ) ``` Programs are entirely optional — `program_id` is nullable everywhere it appears. If your project and cell specification names already say what a test is, you can ignore them. ## Permissions Planned measurement endpoints reuse the existing `cell_measurement` permissions: | Action | Permission | | ------------------------ | ------------------------- | | List, get | `cell_measurement:read` | | Create | `cell_measurement:create` | | Update, schedule, cancel | `cell_measurement:update` | | Delete | `cell_measurement:delete` | ## `project_id` All `client.planned_measurement.*` methods accept an optional `project_id`. When omitted it falls back to the `project_id` configured on the [`Ionworks` client](/api-client) (or the `IONWORKS_PROJECT_ID` env var). Methods raise `ValueError` if no `project_id` is available from any source. ## Next steps Set up the sites, cyclers, and channels that planned measurements schedule onto. Create the real `cell_measurement` that links back to a scheduled plan. # Python API Source: https://docs.ionworks.com/optimize/api Run, monitor, list, update, and cancel optimizations programmatically with the ionworks-api Python client The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package provides a sub-client for running and managing [optimizations](/optimize/overview) programmatically. For installation and authentication, see the [Python API client](/api-client) page. ## Running an optimization ```python theme={null} from ionworks import Ionworks # Reads IONWORKS_API_KEY and IONWORKS_PROJECT_ID from the environment. # project_id is auto-injected into payloads that don't specify it. client = Ionworks() optimization = client.optimization.run({ "name": "Electrode thickness optimization", "parameterized_model_id": "your-parameterized-model-id", "protocol_experiment": { "protocol": "...", "name": "1C Discharge", }, "design_parameters": { "Positive electrode thickness [m]": { "bounds": [50e-6, 100e-6], }, }, "objectives": { "Discharge capacity [A.h]": {"type": "maximize"}, }, }) print(f"Optimization ID: {optimization.id}") print(f"Job ID: {optimization.job_id}") ``` ## Waiting for completion ```python theme={null} result = client.optimization.wait_for_completion( "your-optimization-id", timeout=600, # seconds (default: 600) poll_interval=3, # seconds between polls (default: 3) verbose=True, # print status updates (default: True) ) print(result["status"]) # "succeeded", "failed", or "canceled" print(result.get("metrics")) ``` `wait_for_completion` polls until the optimization reaches a terminal status (`succeeded`, `failed`, or `canceled`) and returns the optimization resource. By default it raises `IonworksError` on `failed` or `canceled`; set `raise_on_failure=False` to get the result dict back instead. ## Listing optimizations ```python theme={null} # List optimizations in the default project # (set IONWORKS_PROJECT_ID or pass project_id= to Ionworks(...)) optimizations = client.optimization.list() # Override the project for a single call optimizations = client.optimization.list(project_id="other-project-id") # Paginate results optimizations = client.optimization.list(limit=10, offset=0) ``` When `project_id` is omitted, `client.optimization.run` and `.list` use the [default project](/api-client#default-project) configured on the Ionworks client. ## Getting an optimization Returns the optimization resource as a flat dictionary. The lifecycle `status` is one of `queued`, `running`, `succeeded`, `failed`, or `canceled`. Result `metrics` and `error` are populated once the optimization reaches a terminal state. ```python theme={null} result = client.optimization.get("your-optimization-id") print(result["id"], result["status"]) print(result.get("metrics")) # populated when status == "succeeded" print(result.get("error")) # populated when status == "failed" ``` The response used to be split into separate `optimization` and `job` keys. The job is no longer exposed — `status`, `metrics`, and `error` now live on the optimization resource itself. Update any code that reads `result["job"]["status"]` to read `result["status"]`. ## Updating an optimization ```python theme={null} optimization = client.optimization.update("your-optimization-id", { "name": "Electrode optimization v2", "description": "Updated bounds", }) ``` ## Inspecting the parameter trace Design optimizations and [data fits](/build/parameterize/data-fitting/overview) log the optimizer's per-iteration progress to the job's metadata. This is the same data that powers the parameter and cost-convergence plots in Studio. Use `client.job.get_parameter_trace` to pull it down as a list of dicts (one per saved iteration, oldest first): ```python theme={null} trace = client.job.get_parameter_trace(optimization.job_id) for entry in trace: print(entry["inputs_unscaled"], entry["cost"], entry["best_cost"]) ``` Each entry contains: | Key | Description | | -------------------------- | ---------------------------------------------------------------------------------------------- | | `cost` | Objective value at this iteration. | | `best_cost` | Best (lowest) objective value seen up to this iteration. | | `inputs` | Scaled parameter values at this iteration. | | `inputs_unscaled` | Unscaled (physical) parameter values, keyed by parameter name. Use these for parameter traces. | | `multistart_job_id` | Index of the multistart this entry belongs to, when applicable. | | `outputs` / `best_outputs` | Model outputs at this iteration. Present for design-objective runs. | Saves are throttled (roughly every 100 iterations or every 5 seconds), so the trace is a sampled subset of the optimizer's evaluations rather than every single one. The list is empty when live progress updates were disabled for the run, and there is no per-iteration wall-clock timing. ## Canceling an optimization Cancels a queued or running optimization and returns the updated optimization resource. ```python theme={null} result = client.optimization.cancel("your-optimization-id") print(result["status"]) # "canceled" ``` You can find the ID for any resource from the Ionworks Studio web app. The ID is displayed in the URL when you navigate to a resource's detail page. # Optimization Overview Source: https://docs.ionworks.com/optimize/overview Automatically find optimal battery design and charging parameters with multistart Bayesian and evolutionary optimizers # Optimization Optimization in Ionworks Studio allows you to automatically find the best parameter values for your battery models. Instead of manually running many simulations with different parameter combinations, the optimization engine intelligently searches the parameter space to find values that maximize or minimize your objectives while respecting constraints. ## When to Use Optimization Optimization is ideal for: * **Charge Protocol Design** - Find optimal charging currents and voltage profiles that minimize charge time while avoiding lithium plating * **Cell Design** - Optimize electrode thicknesses, porosities, and other geometric parameters to maximize energy density or power capability * **Multi-Objective Trade-offs** - Balance competing goals like capacity vs. charge time, or energy vs. power ## How Optimization Works The optimization process follows these steps: 1. **Name your optimization** (optional) - Give the run a descriptive name, or leave it blank to auto-generate one 2. **Select a cell and model** - Choose the cell specification and parameterized model to optimize against 3. **Define Objectives** - Specify what you want to maximize or minimize, and add constraints that must be respected 4. **Define Parameters** - Select which model parameters to optimize and set their bounds (minimum and maximum values) 5. **Configure Algorithm** - Set the number of multistarts and maximum iterations 6. **Run Optimization** - The algorithm explores the parameter space to find the optimal values for the given objective and constraints 7. **Review Results** - Compare the optimized results against your baseline and examine the iteration history ### Multistart Optimization Optimization problems can have multiple local optima. To increase the chance of finding the global optimum, Ionworks uses **multistart optimization**: * Multiple optimization runs start from different initial points * Each run converges to a local optimum * The best result across all runs is selected as the final answer The default number of multistarts depends on your chosen optimizer. Global optimizers like Differential Evolution default to 1 multistart since they explore the search space broadly on their own. Local optimizers like XNES and CMA-ES default to 4. You can always adjust this to balance thoroughness and computation time. See [Parallelisation](/optimize/parallelization) for details on how CPUs are allocated across multistart runs. ## Optimization Templates Ionworks provides pre-configured **optimization templates** to help you get started quickly. Templates are scoped to individual projects, so each project can have its own set of templates tailored to its specific needs. Built-in **system templates** (Design and Charge) are available in every project as read-only starting points. ### Design Optimization Optimize cell design parameters like electrode thicknesses and material properties to achieve target performance characteristics. **Common use cases:** * Maximize cell capacity for a given form factor * Optimize electrode thickness ratios for fast charging * Balance energy density and power capability ### Charge Optimization Optimize charging protocol parameters to minimize charge time while respecting safety constraints. **Common use cases:** * Multi-step constant current charging * Minimizing 0-80% charge time * Avoiding lithium plating (maintaining positive anode potential) You can also [copy templates between projects](/optimize/templates#copying-templates-between-projects) to reuse configurations across your organization. ## Key Concepts ### Parameters Parameters are the values you want to optimize. Each parameter has: * **Name** - The model parameter to vary (e.g., "Positive electrode thickness \[m]") * **Bounds** - The minimum and maximum allowed values * **Initial Value** - The starting point for optimization (defaults to model value) ### Objectives Objectives define what you want to achieve. Each objective includes: * **Experiment** - The simulation protocol to run (in UCP format) * **Goals** - What to maximize or minimize * **Constraints** - Limits that must be respected ### Metrics Metrics extract values from simulation results for use in goals and constraints: | Metric Type | Description | Example Use | | -------------- | ----------------------------------- | ----------------------- | | **Maximum** | Maximum value during simulation | Peak temperature | | **Minimum** | Minimum value during simulation | Minimum anode potential | | **Mean** | Average value over time | Average power | | **Sum** | Total accumulated value | Energy throughput | | **Time** | Value at a specific time point | Final capacity | | **SOC** | Value at a specific state of charge | Voltage at 80% SOC | | **Voltage** | Value at a specific voltage | Time to reach 4.2V | | **PointBased** | Single-point value (no time series) | Cell cost | ### Constraints Constraints define limits that must be respected. If a constraint is violated, a penalty is added to the cost function. Each constraint has: * **Action** - "GreaterThan" or "LessThan" * **Value** - The threshold value * **Penalty** - The penalty weight for violations (default: 1e6) ## Algorithm Configuration ### Optimizer selection Ionworks supports several optimization algorithms. The default is **Differential Evolution**, a global optimizer that works well across a wide range of problems without manual tuning. | Optimizer | Type | Best for | Default multistarts | | ------------------------------------ | ---------------------------- | -------------------------------------------------------------------------------- | ------------------- | | **Differential Evolution** (default) | Population-based, global | General-purpose optimization; noisy or multimodal landscapes | 1 | | **XNES** | Population-based | General-purpose; smooth landscapes | 4 | | **CMA-ES** | Population-based | Difficult non-separable problems | 4 | | **PSO** | Population-based, global | Broad exploration via swarm intelligence | 1 | | **Nelder-Mead** | Single-point, gradient-free | Fast convergence on simple problems; may find local optima | 4 | | **Bayesian Optimization** | Surrogate (Gaussian process) | Expensive simulations with up to \~10 parameters and a small evaluation budget | 1 | | **TuRBO** | Surrogate, trust-region | Expensive simulations evaluated in parallel batches; scales to higher dimensions | 1 | | **SOBER** | Surrogate, batch quadrature | Wide parallel batches where quadrature-style batch selection is desirable | 1 | Global optimizers like Differential Evolution and PSO explore the search space broadly on their own, so they typically need only 1 multistart. Local optimizers benefit from multiple multistarts to avoid getting stuck in local optima. Surrogate optimizers fit a probabilistic model of the objective so every evaluation counts, which makes them the best choice when each simulation is expensive. ### When to choose a surrogate optimizer Surrogate methods do more work *per round* in order to save simulations: * **Bayesian Optimization** — sequential, sample-efficient. Use it when each evaluation is expensive, you have ten or fewer parameters, and you only have budget for a few dozen simulations. * **TuRBO** — trust-region Bayesian optimization designed for parallel batches and higher dimensions. Set the warm-up size to match the parallel batch width so the first round fully uses the available workers. * **SOBER** — batch Bayesian optimization with quadrature-style recombination. Useful when you want wide, diverse batches; for pure best-cost optimization, prefer TuRBO or Bayesian Optimization. See the [Design Optimization guide](/guide/optimize/design-optimization#choosing-an-optimizer) for a deeper discussion of when each algorithm pays off. ### Algorithm parameters You can tune the optimization algorithm: * **Multistarts** - Number of parallel optimization runs (1-50). The default depends on the optimizer — global optimizers default to 1, while local optimizers default to 4. * **Max Iterations** - Maximum iterations per run (10-1000, default: 100) * **Population size** (Differential Evolution and PSO) - Number of candidates evaluated per generation. Defaults scale with the number of fit parameters and are **capped at 40** to keep a single generation from ballooning worker demand on higher-dimensional problems. See [Population size](/optimize/running-optimization#population-size) for details. More multistarts increase reliability but take longer. More iterations allow finer convergence but may not be needed for simple problems. ## Next Steps * Learn about [Optimization Templates](/optimize/templates) to understand available templates * Follow the guide on [Running an Optimization](/optimize/running-optimization) for step-by-step instructions * Read the [Design Optimization](/guide/optimize/design-optimization) concept guide for the mathematical formulation and design-space intuition # Parallelization Source: https://docs.ionworks.com/optimize/parallelization How Ionworks distributes multistart and population-based optimizations across an autoscaling pool of workers Ionworks distributes optimization work across a pool of worker processes that scales automatically with demand. This page explains how resources are allocated across multistart runs and when distributed evaluation is used to accelerate population-based optimizers. ## Two Levels of Parallelism Ionworks applies parallelism at two levels during optimization: ### Batch-level: Multistart Runs When you configure multiple multistarts, all runs are coordinated from a single driver and share one worker pool. The driver dispatches each start's evaluations onto the pool and processes results as workers finish — it does **not** launch a separate task or reserve a dedicated CPU per start. Concurrency across starts is bounded by the pool size: if total demand exceeds the available workers, evaluations queue and run as workers free up. ### Optimizer-level: Distributed Evaluation Population-based optimizers evaluate a population of candidate solutions every generation. These evaluations are dispatched across the shared worker pool, which scales to host them. The following optimizers are **population-based** and support distributed evaluation: | Optimizer | Description | | ------------------------------------ | -------------------------------------------------- | | **Differential Evolution** (default) | Adaptive mutation and crossover with global search | | **CMA-ES** | Covariance Matrix Adaptation Evolution Strategy | | **PSO** | Particle Swarm Optimization | | **XNES** | Exponential Natural Evolution Strategy | Non-population-based optimizers (e.g. Nelder-Mead) evaluate one candidate at a time, so a single such run cannot parallelize internally. Multiple non-population multistarts still run concurrently across the shared pool — one in-flight evaluation per start. #### Streaming (async) evaluation The driver streams results: it feeds each candidate result back to its optimizer as soon as a worker finishes, rather than waiting for the entire generation to complete. This reduces idle time when individual evaluations vary in duration — fast evaluations are processed immediately while slower ones continue in the background. It is automatic for all population-based optimizers (Differential Evolution, CMA-ES, PSO, XNES). Streaming evaluation does not change optimization results — it only affects throughput. Each optimizer receives the same candidate-result pairs regardless of the order in which workers finish. ## Resource Allocation The worker pool is sized to the fit's **demand**: `num_starts × generation width × per-point tasks`, where the generation width is the population size for population-based optimizers (1 for non-population optimizers) and per-point tasks equals the number of objectives when objective-level parallelism is enabled. This demand is capped by the total worker supply available — i.e. `min(demand, supply)`. There is no fixed per-job worker count. The pool scales up and down as jobs start and finish. When you run against your own machine, the currently-free CPU count is used as the supply cap. The pool is always built with at least one worker; when only one worker is available the fit's evaluations run effectively sequentially through it. The pool size is determined automatically from the fit's demand and available supply. No manual worker-count configuration is required. ## Scenarios The following examples illustrate how resources are allocated in different configurations. ### Scenario 1: Multiple Multistarts with a Population-based Optimizer **4 multistarts with CMA-ES** All four starts are coordinated from the driver and share one worker pool sized to the combined demand across all starts — `num_starts × population × per-point tasks` — capped by the available supply. The pool scales to host the work, and every start's population evaluations are dispatched across it. ### Scenario 2: Single Multistart with a Population-based Optimizer **1 multistart with PSO** The driver coordinates one run. The worker pool is sized to that run's demand (`1 × population × per-point tasks`), capped by the available supply. All workers serve the population evaluations for that single run. ### Scenario 3: Many Multistarts **32 multistarts with CMA-ES** The shared worker pool is sized to the aggregate demand across all 32 starts, capped by the total supply. The pool scales to meet demand; until it has fully scaled, evaluations queue and start as workers become available. ### Scenario 4: Multiple Multistarts on Your Own Machine **4 multistarts with CMA-ES running locally** The pool is sized to the fit's demand, capped by your machine's currently-free CPUs (the supply). The starts share whatever pool that yields; if fewer CPUs are free than the fit demands, evaluations queue and run as workers free up. ### Scenario 5: Single Multistart with a Non-population-based Optimizer **1 multistart with Nelder-Mead** The driver coordinates one run. Because Nelder-Mead is not population-based, it evaluates one candidate at a time, so this single run runs effectively sequentially on one worker. ## Summary | Scenario | Pool sizing | Parallel eval | Streaming results | Behavior | | --------------------------------------- | ----------------------------------------------------- | ------------- | ----------------- | ---------------------------------------------------------------------------- | | N multistarts, population-based | demand-driven, capped by supply | Yes | Yes | Pool scales to host the work; results processed as they arrive | | N multistarts, population-based (local) | demand-driven, capped by free local CPUs | Yes | Yes | Pool capped by free local CPUs; results processed as they arrive | | 1 multistart, population-based | demand-driven, capped by supply | Yes | Yes | Workers evaluate the population in parallel with streaming dispatch | | N multistarts, non-population-based | demand-driven (one point per start), capped by supply | Across starts | Yes | Starts run concurrently across the pool; each start is internally sequential | | 1 multistart, non-population-based | 1 worker | No | — | Single run, sequential evaluation | ## Next Steps * Learn about [Optimization Templates](/optimize/templates) to understand available templates * Follow the guide on [Running an Optimization](/optimize/running-optimization) for step-by-step instructions # Running an Optimization Source: https://docs.ionworks.com/optimize/running-optimization Step-by-step workflow to configure cells, parameters, objectives, constraints, and algorithms, then run and review an optimization # Running an Optimization This guide walks you through the complete workflow for running an optimization in Ionworks Studio. ## Prerequisites Before starting an optimization, ensure you have: * A [Cell Specification](/core-concepts/cells) defined for your battery * A [Parameterized Model](/build/parameterized-models) created for that cell * A clear understanding of what you want to optimize ## Step 1: Name your optimization (optional) You can give your optimization a descriptive name to identify it in the optimizations list, or leave the name blank to have one generated automatically. 1. Navigate to your project's **Optimizations** page 2. Click **New Optimization** and select a template. You can choose from the built-in system templates (Design or Charge) or any [project templates](/optimize/templates#template-types) you've created. 3. Optionally enter a **name** for your optimization (e.g., "Fast charge protocol v2" or "Electrode thickness sweep") If you leave the name blank, Ionworks generates a unique name for you (e.g., "Optimization a1b2c3d4"). You can always rename it later from the optimization detail page. Choose a name that describes the goal or configuration of the run. This makes it easier to compare results when you have multiple optimizations in a project. ## Step 2: Choose cell and model Select the cell specification and parameterized model for your optimization. ### Cell specification In the **Cell & Model** section, select your cell from the dropdown. The cell specification provides: * Nominal capacity (used to convert C-rates to currents) * Voltage limits (used as defaults in experiments) * Chemistry information ### Parameterized model Next, select the parameterized model that will be used for simulations during optimization. 1. Select a model from the dropdown 2. Only models compatible with your selected cell will appear 3. The model's parameter values become the baseline for optimization The parameterized model provides the starting point for all parameters. During optimization, only the parameters you explicitly add to the optimization will be varied—all others remain at their model values. ## Step 3: Configure objectives Objectives define what you want to achieve. Each objective includes an experiment, goals, and optional constraints. ### Add an objective 1. Click **Add Objective** to create a new objective 2. Give it a descriptive name (e.g., "charge\_time", "energy\_density") ### Define the experiment The experiment specifies the simulation protocol to run. Enter it in the experiment editor in [UCP format](/simulate/universal-cycler-protocol): ```yaml theme={null} - CC Charge: - Charge: mode: Current value: Input["I_charge"] ends: - "Voltage > 4.2" - CV Hold: - Charge: mode: Voltage value: 4.2 ends: - "C-rate < 0.05" ``` Use `Input["parameter_name"]` syntax to reference parameters you want to optimize. The editor is the same multi-line code editor used on the [Protocols](/simulate/protocols) page. It supports syntax highlighting, multi-line entry, and large protocols. As you type, it parses your UCP so formatting issues surface before you submit. You can paste an existing UCP protocol straight in or start from a template and edit in place. If your UCP experiment includes an `initial_temperature` in the [`global` configuration](/simulate/universal-cycler-protocol#global-configuration), the optimizer automatically sets the simulation's initial and ambient temperature to that value (converted from Celsius to Kelvin). Similarly, `initial_state_type` and `initial_state_value` are used to set the initial state of charge or voltage. You do not need to add these as separate parameters. [EIS steps](/simulate/universal-cycler-protocol#eis-steps) are not supported in design optimization experiments. If your experiment includes an `EIS` step, the optimization cannot be submitted. Remove EIS steps before running a design optimization — EIS measurements are best performed in a standalone [simulation](/simulate/simulations). ### Add custom variables (optional) If you need to optimize based on derived quantities: 1. Click **Add Variable** in the Custom Variables section 2. Enter a **Variable Name** (e.g., "Anode Potential") 3. Enter a **PyBaMM Expression** (e.g., `pybamm.CoupledVariable("Negative electrode surface potential difference [V]")`) 4. The expression is validated when you click away from the field Custom variables become available in the variable dropdowns for goals and constraints. ### Add goals Goals define what to maximize or minimize: 1. Click **Add Goal** 2. Configure the goal: * **Name**: Descriptive name (e.g., "minimize\_time") * **Weight**: Relative importance (default: 1.0) * **Action**: "Maximize" or "Minimize" * **Variable**: The output variable to optimize * **Metric Type**: How to extract the value (see below) * **Value** (for crossing metrics): The crossing point **Metric Type Options:** | Type | Use Case | Value Field | | ---------- | ---------------------------------------- | ------------------------------------------------ | | Maximum | Peak value (e.g., max temperature) | Not needed | | Minimum | Lowest value (e.g., min anode potential) | Not needed | | Mean | Average value | Not needed | | Sum | Accumulated total | Not needed | | PointBased | Single-point values | Not needed | | Last | Final value of the variable | Not needed | | Time | Value at specific time(s) | Time in seconds, or a list of times (-1 for end) | | SOC | Value at specific SOC | SOC as fraction (0-1) | | Voltage | Value at specific voltage | Voltage in V | Supplying a list of times to the `Time` metric (the vector form) is only supported for continuous, experiment-free solves with no per-cycle or per-step sub-solutions to slice. It is not supported inside a `DesignObjective` — use a single time value there, or wrap the metric in an iterative (per-cycle / per-step) metric to target specific segments. #### Evaluate per cycle or step For multi-cycle experiments you can wrap any goal or constraint metric with **Evaluate per step/cycle** to apply it only to selected cycles or steps: * **Per Cycle** — pick a list of cycle indices and (optionally) one step within each cycle. * **Per Step** — pick a list of absolute step indices in the flattened experiment. The form shows the experiment's bounds (total cycles, steps per cycle, total steps) and rejects out-of-range indices. See [Iterative metrics](/optimize/templates#iterative-metrics-per-cycle-and-per-step) for examples. ### Validate against experiment steps Each objective has a **Validate against experiment steps** toggle (enabled by default). When enabled, the optimizer checks that the simulation completes all steps defined in the experiment protocol. If any step is skipped or not reached, the evaluation is penalized. Disable this toggle when your optimization may cause simulations to terminate before completing all steps — for example, if the optimizer is exploring aggressive parameter combinations that trigger early cutoff conditions. In these cases, the optimizer can still extract useful information from partial simulations without being penalized for incomplete experiments. ### Add Constraints Constraints define limits that must be respected: 1. Click **Add Constraint** 2. Configure the constraint: * **Name**: Descriptive name (e.g., "no\_plating") * **Penalty**: Weight for violations (default: 1e6) * **Action**: "GreaterThan" or "LessThan" * **Constraint Value**: The threshold * **Variable**: The output variable to constrain * **Metric Type**: How to extract the value **Example: Prevent lithium plating** ``` Name: anode_potential_constraint Action: GreaterThan Constraint Value: 0 Variable: Negative electrode surface potential difference [V] Metric Type: Minimum ``` This ensures the minimum anode potential stays above 0V throughout the simulation. ## Step 4: Configure parameters Parameters define what the optimizer can adjust. ### Add parameters 1. Click **Add Parameter** 2. Select a parameter from the dropdown (grouped by category) 3. Set the **Initial Value** (defaults to model value) 4. Set the **Lower Bound** (minimum allowed value) 5. Set the **Upper Bound** (maximum allowed value) Parameters referenced in your experiment as `Input["parameter_name"]` will automatically appear in the "Experiment Inputs" group. Model parameters appear in their respective groups (Cell, Anode, Cathode, etc.). ### Parameter validation Before the optimization runs, Ionworks validates that every parameter you add is actually used by at least one objective. **Valid parameter uses include:** * Direct model parameters (e.g., `Positive electrode thickness [m]`) * Parameters referenced in experiment steps via `Input["parameter_name"]` * Parameters linked to model parameters through expressions * Parameters used in constraint or penalty expressions * Initial state-of-charge parameters (e.g., `Initial SOC [%]`, `Initial voltage [V]`) * Temperature parameters from the UCP `global` configuration (e.g., `Initial temperature [K]`, `Ambient temperature [K]`) **If validation fails**, you'll see an error listing: * The unused parameters * The parameters the model actually uses * Suggestions for similar parameter names (in case of typos) **Example error:** ``` The following fit parameters are not used by any objective's model and are not referenced by any expression that maps to a model parameter: ['Electrolyte viscosity']. The model uses these parameters: ['Positive electrode thickness [m]', ...]. Remove the unused parameters or add expressions that link them to model parameters. ``` If you need a parameter that isn't directly in the model, you can link it via an expression. For example, define a `scale_factor` parameter and use it in an expression like `"Positive electrode thickness [m]": 100e-6 * scale_factor`. ### Parameter bounds Choose bounds that are: * Physically realistic * Within manufacturing capabilities * Wide enough to allow meaningful optimization * Narrow enough to avoid unphysical solutions ## Step 5: Configure algorithm Fine-tune the optimization algorithm for your problem: ### Optimizer Select the optimization algorithm. The default is **Differential Evolution**, a global optimizer that reliably finds good solutions without requiring hyperparameter tuning. * **Differential Evolution** (default) — A population-based global optimizer that uses adaptive mutation and crossover to explore the search space. Robust on noisy or multimodal problems. Uses 1 multistart by default since it performs global search internally. * **XNES / CMA-ES** — Population-based evolution strategies suited for different problem structures. Use multiple multistarts (default: 4) for broader coverage. * **PSO** — Particle swarm optimizer that explores broadly via swarm intelligence. Uses 1 multistart by default. * **Nelder-Mead** — A gradient-free simplex method. Fast but may converge to local optima, so multiple multistarts (default: 4) are recommended. * **Bayesian Optimization** — Gaussian-process surrogate optimizer for expensive simulations. Sample-efficient for problems with up to \~10 parameters and limited evaluation budgets. Proposes one candidate per round by default. * **TuRBO** — Trust-region Bayesian optimization for expensive problems run in parallel. Picks a batch of candidates each round and shrinks or grows its trust region based on observed improvement. Works well when many workers are available and the problem has more than \~10 parameters. * **SOBER** — Batch Bayesian optimization that selects diverse candidates via quadrature-style recombination. Use it when you want wide batches; for pure best-cost optimization prefer TuRBO or Bayesian Optimization. See the [Optimization overview](/optimize/overview#optimizer-selection) for a comparison table and the [Design Optimization guide](/guide/optimize/design-optimization#choosing-an-optimizer) for guidance on picking between them. The surrogate optimizers (Bayesian Optimization, TuRBO, SOBER) are available on the platform with no extra installation required — their `torch`, `botorch`, and `gpytorch` dependencies are handled automatically, whether you submit through the Studio UI or the API. ### Async evaluation mode When distributed workers are available, population-based optimizers automatically use **async evaluation mode**. In this mode, the optimizer submits candidate evaluations to workers and processes results as they arrive rather than waiting for the full batch to complete. This improves throughput when individual simulations vary in duration. You do not need to configure this manually — async mode is enabled automatically when the infrastructure supports it. See [Parallelization](/optimize/parallelization#streaming-async-evaluation) for details. ### Population size For the population-based optimizers **Differential Evolution** and **PSO**, the default population size scales with the number of fit parameters (`15 × N` for DE, `10 × N` for PSO) and is now **capped at 40**. Anything above 40 rarely improves solution quality on typical battery fits but linearly increases the number of simulations per generation — and therefore the worker demand and wall-clock time. * If your problem has 3 or fewer parameters, the default falls below the cap and is used as-is. * If your problem has many parameters, the cap keeps a single generation from growing into hundreds of evaluations by default. * You can still override the population size explicitly (for example, from the Python pipeline via `DifferentialEvolution(population_size=...)` or `PSO(population_size=...)`) if a specific problem benefits from a larger population. The cap only affects the *default* — it does not change any explicit value you configure. Other population-based optimizers (XNES, CMA-ES) are unaffected. ### Number of multistarts Controls how many independent optimization runs to perform. The default depends on the optimizer — global optimizers (Differential Evolution, PSO) default to 1, while local optimizers default to 4. | Value | Trade-off | | ----- | ----------------------------------------------- | | 1 | Fastest; sufficient for global optimizers | | 2-4 | Good balance for local optimizers | | 8-16 | High reliability, longer runtime | | 32 | Maximum reliability, for critical optimizations | **Recommendation:** For Differential Evolution (the default), start with 1 multistart. For local optimizers like XNES or CMA-ES, start with 4. Increase if results seem inconsistent across runs. ## Step 6: Run optimization 1. Review your configuration in the **Form Data Preview** section 2. Click **Create Optimization** 3. You'll be redirected to the optimization detail page where you can monitor progress ### During optimization While the optimization runs, you can monitor: * **Status**: Current phase (queued, running, succeeded, failed, or canceled) * **Progress**: Which multistart is running * **Iteration History**: Cost function values over iterations ### Optimization phases Optimizations move through a small lifecycle of status values: 1. **queued**: Waiting for compute capacity to become available 2. **running**: Multistart optimizations are executing and combining results 3. **succeeded**: Final results are available 4. **failed**: An error occurred during the run 5. **canceled**: The optimization was canceled before reaching a terminal state `succeeded`, `failed`, and `canceled` are terminal — the optimization will not transition out of them. These are the same values returned by the Python API in `result["status"]`; see the [Python API page](/optimize/api#getting-an-optimization). ## Step 7: Review results Once completed, review the optimization results: ### Optimal parameters The table shows the optimal values found for each parameter, compared to the initial/baseline values. ### Performance comparison Compare key metrics between: * **Baseline**: Results using initial parameter values * **Optimized**: Results using optimal parameter values ### Iteration history The convergence plot shows how the cost function evolved during optimization. Look for: * Incremental reductions (good) * Large positive jumps (constraint violations) * Long flat regions (optimizer has likely converged) The optimization algorithms are implemented to minimize cost. As such, the goals and constraints are formulated to align with this convention when integrated with the optimization loop. This ensures that convergence is always presented as minimization. ### Time series plots Compare simulation outputs (voltage, current, etc.) between baseline and optimized cases to understand how the optimization improved performance. ## Canceling an optimization You can cancel an optimization while it is still in the **Queued** or **Running** state. ### From the detail page 1. Navigate to the optimization you want to cancel 2. Click the **Cancel** button (visible only while the optimization is active) 3. Confirm the cancellation in the dialog ### From the list page 1. Navigate to your project's **Optimizations** page 2. Select one or more active optimizations 3. Click the **Cancel** bulk action 4. Confirm the cancellation Optimizations that have already finished are automatically skipped during bulk cancellation. Once confirmed, the optimization job and all of its child jobs (individual multistart runs) are moved to a **Canceled** status. Any work that was already completed before cancellation is lost. Canceling an optimization cannot be undone. You will need to create and run a new optimization if you still need results. ## Editing an optimization After creating an optimization, you can update its name and description at any time. This is useful for keeping your optimization list organized as you iterate on different configurations. ### How to edit You can open the edit dialog from two places: * **From the optimizations list** — click the three-dot menu on any optimization row and select **Edit** * **From the optimization detail page** — click the **Edit** button in the header In the dialog, you can change the optimization's **name** and **description**. Click **Save Changes** to apply. Only the name and description can be edited. To change parameters, objectives, constraints, or algorithm configuration, clone the optimization and create a new one with the updated settings. ## Cloning an optimization Click **Clone** on any completed optimization to create a new optimization pre-filled with the same cell, model, parameters, objectives, and algorithm settings. This is useful when you want to re-run with minor adjustments — for example, narrowing parameter bounds based on initial results or adding a new constraint. ## Deleting an optimization You can permanently delete an optimization and all its results, either one at a time from the detail page or in bulk from the list page. ### From the detail page 1. Navigate to the optimization you want to delete 2. Click the **Delete** button 3. Confirm the deletion in the dialog ### From the list page 1. Navigate to your project's **Optimizations** page 2. Select one or more optimizations using the row checkboxes 3. Click the **Delete** bulk action in the toolbar 4. Confirm the deletion in the dialog Each optimization is deleted individually, so any failures are reported per-row while the rest of the selection continues. A success toast summarizes how many optimizations were removed. The bulk **Delete** action is only visible to users with the `optimization:delete` permission. Users without delete permission can still see and use the bulk **Cancel** action if they have `optimization:update`. This action cannot be undone. Deleted optimizations and all of their results are permanently removed. ## Best Practices ### Start simple Begin with: * Fewer parameters (2-4) * Wider bounds * Lower multistarts (2-4) * Fewer iterations (50) Increase complexity once you understand the problem. ### Check constraint satisfaction If optimal results violate constraints: * Increase penalty values * Narrow parameter bounds * Check that constraints are physically achievable ### Validate results After optimization: * Run a simulation with optimal parameters to verify * Check that results are physically reasonable * Consider manufacturing tolerances ### Iterate Optimization is often iterative: 1. Run initial optimization 2. Review results and refine bounds 3. Add/remove parameters or constraints 4. Re-run with adjusted configuration ## Troubleshooting ### Partial Solutions on Solver Failure When the solver encounters a numerical error mid-simulation (e.g., instabilities for certain parameter combinations), it returns a partial solution containing results up to the point of failure instead of crashing. The objective uses this partial time-series directly, giving the optimizer real data from the portion of the simulation that succeeded. Partial solutions improve convergence because the optimizer receives meaningful gradient information from the successful portion of the simulation, rather than losing the evaluation entirely. ### Optimization Fails * Check that parameter bounds are realistic * Verify experiment syntax is valid * Ensure constraints are achievable * Check model compatibility with experiment ### Poor convergence * Increase max iterations * Narrow parameter bounds * Simplify the objective function * Check for competing constraints ### Inconsistent results * Increase number of multistarts * Check for multiple local optima * Review constraint penalties ## Next steps * Review [Optimization Overview](/optimize/overview) for conceptual understanding * Learn about [Templates](/optimize/templates) for pre-configured starting points * Explore [Simulations](/simulate/simulations) to understand the underlying simulation process # Optimization Templates Source: https://docs.ionworks.com/optimize/templates Pre-configured Design and Charge templates with parameters, objectives, and constraints for cell design and fast-charging optimization # Optimization Templates Optimization templates provide pre-configured starting points for common optimization scenarios. Each template defines default parameters, objectives, experiments, and constraints that you can customize for your specific needs. ## Template types Ionworks Studio has two types of optimization templates: * **System templates** — Built-in templates (Design, Charge) provided by Ionworks. These are read-only and available in every project. * **Project templates** — Templates you create within a specific project. These are only visible to members of that project and can be fully edited or deleted. When you open the optimization templates page for a project, you see both system templates and any project-specific templates. The **Type** column indicates whether a template is "System" or "Project". ## Available system templates ### Design Template The **Design** template is optimized for cell design optimization, where you want to find optimal geometric and material parameters for your battery cell. **Default Configuration:** * **Objectives:** Maximize capacity or energy density * **Parameters:** Electrode thicknesses, porosities, particle sizes * **Constraints:** Manufacturing limits, safety requirements **Typical Use Cases:** * Optimizing electrode thickness ratios for a target application * Balancing energy density vs. power capability * Finding optimal particle sizes for rate capability ### Charge Template The **Charge** template is designed for charging protocol optimization, where you want to find optimal current profiles to minimize charge time while respecting safety constraints. **Default Configuration:** * **Objectives:** Minimize total charge time (uses the `Last` metric on `Time [s]` to capture the final simulation time) * **Parameters:** Two charging currents (`I_charge_0`, `I_charge_1`) bounded between 0.1–10 A * **Experiment:** Two CC steps followed by a CV hold to 4.20 V (`C-rate < 0.05`), starting from 0% SOC * **Constraints:** Minimum negative-electrode surface potential difference at the separator interface > 0 V (to avoid lithium plating) **Typical Use Cases:** * Multi-step constant current (MSCC) charging optimization * Fast charging protocol design * Balancing charge speed vs. battery health The Charge template was simplified from six CC steps to two CC + CV. If you need finer-grained control (more CC stages, different cut-off voltages), copy the template into your project and add steps to the experiment as [described below](#customizing-templates). ## Template Structure Each optimization template consists of: ### Parameters Section Defines which model parameters will be optimized: ```json theme={null} { "parameters": { "I_charge_1": { "bounds": [-5.0, -0.5], "initial_value": -2.0 }, "I_charge_2": { "bounds": [-5.0, -0.5], "initial_value": -1.5 } } } ``` * **bounds**: \[min, max] range for the parameter * **initial\_value**: Starting point for optimization For charging currents, PyBaMM uses **negative values for charging** (positive \= discharging). So a charge current of -2.0 A means charging at 2.0 A. ### Objectives Section Defines the experiments to run and what to optimize: ```json theme={null} { "objectives": { "charge_objective": { "experiment": "...", "metrics": { "charge_time": { "action": "Minimize", "weight": 1.0, "metric": { "type": "Time", "variable": "Time [s]", "value": -1 } } }, "constraints": { "anode_potential": { "action": "GreaterThan", "penalty": 1000000, "metric": { "type": "Minimum", "variable": "Anode potential [V]" }, "value": 0 } } } } } ``` ### Experiment Definition Experiments are defined using [UCP format](/simulate/universal-cycler-protocol). Templates use `Input["parameter_name"]` syntax to reference optimizable parameters: ```yaml theme={null} - CC Charge Step 1: - Charge: mode: Current value: Input["I_charge_1"] ends: - "Voltage > 4.0" - CC Charge Step 2: - Charge: mode: Current value: Input["I_charge_2"] ends: - "Voltage > 4.2" - CV Hold: - Charge: mode: Voltage value: 4.2 ends: - "C-rate < 0.05" ``` ## Custom Variables Templates can define **custom variables** - derived quantities computed from simulation outputs using PyBaMM expressions. These are useful when you want to optimize based on quantities that aren't directly available as model outputs. ### Creating Custom Variables Custom variables are defined per-objective with: * **Name**: A descriptive name for the variable * **Expression**: A PyBaMM expression string **Example expressions:** ```python theme={null} # Specific energy (Wh/kg) pybamm.Variable("Energy [Wh]") / pybamm.Parameter("Cell mass [kg]") # Anode surface potential difference pybamm.CoupledVariable("Negative electrode surface potential difference [V]") ``` Custom variables become available in the variable dropdown for goals and constraints, alongside model variables like "Voltage \[V]" and "Current \[A]". ## Metric Types Templates use various metric types to extract values from simulation results: ### Aggregation Metrics These compute a single value from a time series: | Type | Description | | -------------- | ---------------------------------------------------------------------------------- | | **Maximum** | Highest value during simulation | | **Minimum** | Lowest value during simulation | | **Mean** | Time-averaged value | | **Sum** | Accumulated total | | **Last** | Final value of the variable (e.g., final `Time [s]` for total experiment duration) | | **PointBased** | For values that don't change (e.g., cell properties) | ### Crossing Metrics These evaluate the variable at a specific point: | Type | Value Field | Description | | ----------- | ------------------------ | -------------------------------------------------------------------------------------------------- | | **Time** | Seconds (scalar or list) | Value at specified time (-1 for end). Accepts a list of times to read multiple points in one call. | | **SOC** | 0-1 | Value when reaching specified SOC | | **Voltage** | Volts | Value when reaching specified voltage | **Example: Time to 80% SOC** ```json theme={null} { "type": "SOC", "variable": "Time [s]", "value": 0.8 } ``` **Example: Voltage sampled at multiple times** Pass a list of times to the `Time` metric to read the variable at each time in a single evaluation. The metric returns one value per time, in order. ```json theme={null} { "type": "Time", "variable": "Voltage [V]", "value": [60, 300, 600, -1] } ``` The vector form of the `Time` metric is intended for continuous, experiment-free solves where there are no per-cycle or per-step sub-solutions to slice. It is not supported inside a `DesignObjective` — use a scalar `value` there, or wrap the metric in a `StepwiseMetric` / `CyclewiseMetric` to target specific segments. ### Iterative metrics (per-cycle and per-step) Use **iterative metrics** when you need to evaluate a goal or constraint on a specific subset of cycles or steps inside a multi-cycle experiment, rather than over the whole simulation. They wrap any aggregation or crossing metric and tell the optimizer to apply it to the chosen indices only. There are two wrapper types: | Wrapper | Apply the inner metric to… | Configuration | | --------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | **Per Cycle** (`CyclewiseMetric`) | Selected cycle indices, optionally restricted to one step within each cycle | `cycles` (list of cycle indices) and optional `step` (single step index) | | **Per Step** (`StepwiseMetric`) | Selected absolute step indices in the flattened experiment | `steps` (list of step indices) | In the optimization form, toggle **Evaluate per step/cycle** under any goal or constraint, then choose **Per Cycle** or **Per Step**: * **Per Cycle** exposes a **Step** field (step index within each cycle) and a **Cycles** field that takes a comma-separated list of cycle indices, e.g. `0, 2, 4`. * **Per Step** exposes a **Steps** field that takes a comma-separated list of absolute step indices, e.g. `0, 1, 2`. All indices are 0-based. Studio displays the experiment's available bounds (total cycles, steps per cycle, total steps) inline and flags out-of-range indices before you submit. **Example: minimum anode potential during the high-rate charge step of cycles 0, 5, and 10** ```json theme={null} { "type": "CyclewiseMetric", "cycles": [0, 5, 10], "step": 1, "metric": { "type": "Minimum", "variable": "Negative electrode surface potential difference at separator interface [V]" } } ``` **Example: time to reach 4.2 V on the second step** ```json theme={null} { "type": "StepwiseMetric", "steps": [1], "metric": { "type": "Voltage", "variable": "Time [s]", "value": 4.2 } } ``` Crossing metrics (`Time`, `SOC`, `Voltage`) almost always need to be wrapped in a `StepwiseMetric` or `CyclewiseMetric` so the crossing search runs on the intended segment of the simulation. Studio enables the wrapper toggle automatically for crossing metrics. The optimizer validates wrapper indices against the experiment before submitting. Cycle or step indices outside the experiment's range produce a validation error such as *"Metric 'no\_plating': cycle index 49 is out of range. Experiment has 30 cycle(s) (0–29)."* Update the experiment or the indices to fit within bounds. ## Customizing templates When you select a template, all values are pre-filled but fully editable: 1. **Modify Parameters** - Add/remove parameters, adjust bounds 2. **Edit Objectives** - Change the experiment protocol, add/remove goals 3. **Adjust Constraints** - Modify constraint thresholds and penalties 4. **Add Custom Variables** - Create derived quantities for your specific needs 5. **Configure Algorithm** - Set multistarts and max iterations Templates provide a starting point — you're encouraged to customize them for your specific cell chemistry, form factor, and optimization goals. ## Saving an optimization as a template Once you've tuned an optimization's parameters, objectives, and constraints to a configuration you want to reuse, you can save it as a new project template directly from the optimizations list — no need to rebuild the form by hand. To save an optimization as a template: 1. Open the **Optimizations** list in your project. 2. Click the three-dot menu on the row of the optimization you want to reuse. 3. Select **Save as template**. 4. In the dialog, enter a **Template Name** (required) and an optional description. 5. Click **Save Template**. Studio creates a new project-level template, copies the optimization's saved configuration into it verbatim, and navigates you to the template's detail page so you can rename it, edit it, or kick off a new optimization from it. The optimization's saved configuration and a template's `initial_form_data` share the same schema, so settings like `validate_against_experiment_steps`, iterative metric wrappers, and custom variables are preserved exactly. The template stays in sync with what the optimization actually ran — not a re-derived form view of it. Template names must be unique within a project. If a template with the same name already exists, the save fails — pick a different name and retry. You can only save as a template when the source optimization has a valid saved configuration. Optimizations that were created without a complete config (e.g. partially imported drafts) cannot be turned into templates; rerun the optimization with a valid configuration first. ## Copying templates between projects You can copy any template — including system templates — into another project in your organization. This is useful when you want to reuse a template configuration across multiple projects or create a project-specific version of a system template that you can then customize. To copy a template: 1. Open the optimization templates list in your project 2. Click the three-dot menu on the template you want to copy 3. Select **Copy to project** 4. Choose the target project from the dialog 5. Click **Copy** The copied template appears in the target project as a new project template that you can edit independently. Template names must be unique within a project. If the target project already has a template with the same name, the copy will fail. Rename the conflicting template in the target project first to free up the name, then retry the copy. ## Template permissions Template permissions are managed at the project level: | Action | Required project role | | ---------------- | ----------------------------- | | View templates | Viewer, Contributor, or Admin | | Create templates | Contributor or Admin | | Edit templates | Contributor or Admin | | Delete templates | Admin | System templates are read-only for all users. To modify a system template, copy it to your project first — the copy becomes a fully editable project template. ## Next steps * Follow the [Running an Optimization](/optimize/running-optimization) guide for step-by-step instructions * Review [Simulation documentation](/simulate/simulations) to understand the underlying simulation process # Quickstart Source: https://docs.ionworks.com/quickstart Step-by-step guide to creating a project, cell spec, parameterized model, and running your first battery simulation in Ionworks Studio This guide will walk you through the essential steps to get up and running with Ionworks Studio. We'll create a new project, a cell, a parameterized model, a study, and run a basic simulation. ## Step-by-Step Guide Every organization starts with a [Project](/core-concepts/projects-studies) named **"Default"**, so you have somewhere to work straight away. 1. Navigate to the **Projects** page from the main menu. 2. Click the auto-created **"Default"** project to open it. (You can create your own later with **New Project**, top-right.) Now, let's define the cell you want to work with. A [`Cell Specification`](/core-concepts/cells) is the blueprint for your cell. 1. Navigate to the **Cells** page. 2. Click **"New Cell"**. 3. Fill in the properties, e.g.: * **Name:** A descriptive name, e.g., "My First NMC Cell". * **Chemistry:** e.g., "NMC/Graphite". * **Nominal Capacity:** e.g., `5` Ah. * **Voltage Limits:** e.g., `2.5` V (lower) and `4.2` V (upper). 4. Click **"Create"** to save it. Next, we need a [`Parameterized Model`](/build/parameterized-models) to describe the cell's electrochemical behavior. A parameterized model combines a Model (the mathematical framework) with a specific set of parameters. 1. Navigate to the **Parameterized Models** page. 2. Click **"New Parameterized Model"**. 3. In the creation wizard, select the `Cell Specification` you just created. 4. Select a **Model** to use — for this quickstart, a built-in system model is the quickest start. 5. Choose a starting set of parameters (a built-in parameter set is a good starting point). 6. Review the **parameter validation** results for any warnings or errors. 7. Give your parameterized model a name and click **"Create"** to finalize it. A [Study](/simulate/studies) lives inside a project and is used for a focused investigation. 1. Open your project. The **Studies** panel lists its studies. 2. Click the **+** button (labelled **Create Study**) at the top of the Studies panel. 3. Give your study a name, e.g., "Cycling Test". 4. Click **"Create study"**. Now we're ready to run a [Simulation](/simulate/simulations) within your study. 1. Open your study and, on the **Simulations** tab, click **"New Simulation"**. 2. In step **"Select Cell & Parameterized Model"**, choose the `Cell Specification` and `Parameterized Model` you created. 3. Provide the [protocol](/simulate/protocols) to run: **upload a battery cycler protocol file** (Arbin, Maccor, Neware, Novonix, or BioLogic). Studio parses it into a [Universal Cycler Protocol](/simulate/universal-cycler-protocol). 4. Review the parsed protocol and run it. Ionworks Studio starts the simulation. Once the run completes, it appears on the study's **Simulations** tab. 1. Open the simulation from the list to see its result plots (voltage, capacity, and more). 2. Use the tab's **Table / Visualization** toggle to switch between the run list and comparison plots across runs. Congratulations! You've just run your first simulation in Ionworks Studio. ## Prefer Python? You can drive the same platform from Python with the [`ionworks-api`](/api-client) client, configuring your pipeline with [`ionworks-schema`](/build/parameterize/api). Here's a minimal end-to-end [pipeline](/build/parameterize/overview) — install, submit, and read the result. This one derives the positive electrode's maximum lithium concentration from a known capacity and electrode geometry, so it runs without any data file: ```bash theme={null} pip install ionworks-api ``` ```python theme={null} import ionworks_schema as iws from ionworks import Ionworks # Reads IONWORKS_API_KEY and IONWORKS_PROJECT_ID from the environment. client = Ionworks() pipeline = iws.Pipeline( { "known": iws.direct_entries.DirectEntry( parameters={ "Positive electrode capacity [A.h]": 3.0, "Positive electrode active material volume fraction": 0.65, "Positive electrode thickness [m]": 80e-6, "Electrode area [m2]": 0.1, }, ), "c_max": iws.calculations.ElectrodeCapacity(electrode="positive"), }, name="My First Pipeline", ) submission = client.pipeline.create(pipeline) client.pipeline.wait_for_completion(submission.id, timeout=600) result = client.pipeline.result(submission.id) print(result.result) ``` `ElectrodeCapacity` solves for the one unknown among capacity, concentration, and geometry — here it returns the maximum concentration. Swap in a [`DataFit`](/build/parameterize/data-fitting/overview) element to fit model parameters against your own data. See the [Python API client](/api-client) page for authentication and the [Pipelines API](/build/parameterize/api) page for the full pipeline surface. ## Next Steps Now that you've run a basic simulation, you can explore more advanced features: Upload and manage your battery cycling data for visualization and analysis. Create protocols or use built-in experiment templates. Create focused investigations and compare simulation results. Explore the effect of changing multiple parameters at once. Clone and edit parameterized models to improve their accuracy. Automatically find optimal parameters for your battery design. Use the Python API client to parameterize models from experimental data. # Set up a new project Source: https://docs.ionworks.com/quickstarts/new-project Create a project to group your cells, studies, models, and optimizations, then scope subsequent SDK calls to it. A [project](/core-concepts/projects-studies) groups your cell specifications, studies, models, and optimizations. Create one, then scope your work to it. Install and authenticate first: `pip install ionworks-api` and set `IONWORKS_API_KEY`. See the [Python API client](/api-client) page. ```python theme={null} from ionworks import Ionworks client = Ionworks() # Create a project — its id groups everything you do next. project = client.project.create({ "name": "NMC Characterization", "description": "Q1 2025 NMC cell testing", }) print(project.id) # Find projects later (name is a case-insensitive substring match). projects = client.project.list(name="NMC", order_by="created_at", order="desc") # Studies live inside a project — pass its id to scope the call. study = client.study.create( {"name": "1C Discharge Study"}, project_id=project.id, ) ``` **What's happening** * Two ways to scope work to a project: pass the project id explicitly per call (e.g. `client.study.create(project_id, ...)`), or set `IONWORKS_PROJECT_ID` in your environment so it becomes the default for calls whose `project_id` is optional. * `client.project` has the full CRUD set: `.list()`, `.get(id)`, `.create(data)`, `.update(id, data)`, `.delete(id)`. * Projects and studies have no `create_or_get`. For re-runnable scripts, catch the `CONFLICT` error and read `existing_id` from its detail to reuse an existing one. ## Learn more * [Projects & studies](/core-concepts/projects-studies) * [Organizations](/core-concepts/organizations) * [Python API client](/api-client) — `project_id` argument and `IONWORKS_PROJECT_ID` precedence # Run a simulation in a study Source: https://docs.ionworks.com/quickstarts/run-in-study Run a protocol simulation inside a study — the Studies-page flow — against a saved parameterized model, and read its results. On the Studies page you run a simulation against a saved parameterized model and it's filed under a study. From Python this is the same [`client.simulation.protocol`](/quickstarts/run-simulations) call, with a `study_id` so the run shows up on that study's **Simulations** tab. Install and authenticate first: `pip install ionworks-api` and set `IONWORKS_API_KEY` and `IONWORKS_PROJECT_ID`. See the [Python API client](/api-client) page. ```python theme={null} from ionworks import Ionworks client = Ionworks() # A study to hold the run (see "Set up a new project"), and the parameterized # model to simulate (see "Upload a model"). study = client.study.create({"name": "Cycling Test"}, project_id="your-project-id") parameterized_model_id = "your-parameterized-model-id" protocol = """ global: initial_soc: 1 temperature: 25 steps: - Discharge: mode: C-rate value: 1 ends: - "Voltage < 2.5" """ response = client.simulation.protocol({ "parameterized_model": parameterized_model_id, "protocol_experiment": {"protocol": protocol, "name": "1C discharge"}, "study_id": study.id, }) client.simulation.wait_for_completion(response.simulation_id, timeout=120) result = client.simulation.get_result(response.simulation_id) print(result.time_series) # DataFrame: one row per time point print(result.metrics) # dict of scalar metrics ``` **What's happening** * This is the same call as [protocol simulations](/quickstarts/run-simulations); the `study_id` files the run under a study, so it appears on that study's **Simulations** tab in Studio. * `parameterized_model` takes the id of a model you built (see [Upload a model](/quickstarts/upload-model)), or an inline quick model like `{"capacity": 5.0, "chemistry": "NMC/Graphite"}` for a throwaway run. * Read results with `get_result(...)` — `.time_series`, `.steps`, `.metrics`. To sweep parameters across many runs, use `protocol_batch` (see [Run protocol simulations](/quickstarts/run-simulations)). ## Learn more * [Studies](/simulate/studies) and [Simulations](/simulate/simulations) * [Run protocol simulations](/quickstarts/run-simulations) * [Set up a new project](/quickstarts/new-project) and [Upload a model](/quickstarts/upload-model) # Run protocol simulations & get results Source: https://docs.ionworks.com/quickstarts/run-simulations Submit a single protocol simulation or a DOE batch against a parameterized model, wait for completion, and pull back time-series, step, and metric data. This quickstart runs **protocol simulations** — you provide a [Universal Cycler Protocol](/simulate/universal-cycler-protocol) and simulate it against a model, optionally sweeping parameters as a design of experiments (DOE). To run a simulation inside a study (as on the Studies page), see [Run a simulation in a study](/quickstarts/run-in-study) — it uses the same call with a `study_id`. Install and authenticate first: `pip install ionworks-api` and set `IONWORKS_API_KEY` and `IONWORKS_PROJECT_ID`. See the [Python API client](/api-client) page. ```python theme={null} from ionworks import Ionworks client = Ionworks() protocol = """ global: initial_soc: 1 temperature: 25 steps: - Discharge: mode: C-rate value: 1 ends: - "Voltage < 2.5" """ # --- A single simulation --- # `parameterized_model` takes a parameterized-model id, or an inline # quick model like {"capacity": 5.0, "chemistry": "NMC/Graphite"}. response = client.simulation.protocol({ "parameterized_model": "your-parameterized-model-id", "protocol_experiment": {"protocol": protocol, "name": "1C discharge"}, }) result = client.simulation.wait_for_completion(response.simulation_id, timeout=120) result = client.simulation.get_result(response.simulation_id) print(result.time_series) # DataFrame: one row per time point print(result.steps) # DataFrame: one row per protocol step print(result.metrics) # dict of scalar metrics # --- A DOE batch: sweep a parameter across several values --- responses = client.simulation.protocol_batch({ "parameterized_model": "your-parameterized-model-id", "protocol_experiment": {"protocol": protocol, "name": "thickness sweep"}, "design_parameters_doe": { "sampling": "grid", # grid | random | latin_hypercube "rows": [ {"type": "range", "name": "Positive electrode thickness [m]", "min": 50e-6, "max": 100e-6, "count": 5}, ], }, }) client.simulation.wait_for_completion([r.simulation_id for r in responses], timeout=3600) results = [client.simulation.get_result(r.simulation_id) for r in responses] ``` **What's happening** * Protocols are [Universal Cycler Protocol](/simulate/universal-cycler-protocol) (UCP) YAML, passed inline or as a saved protocol id. Every run needs a `parameterized_model` — a parameterized-model id or an inline quick model. * **Single** = `client.simulation.protocol(...)`; **batch/DOE** = `client.simulation.protocol_batch(...)` with a `design_parameters_doe` block whose `rows` sweep named parameters under a `sampling` strategy. * `wait_for_completion(...)` accepts one id or a list. `get_result(id)` returns `.time_series` and `.steps` dataframes (joinable on `Step count`) plus a `.metrics` dict. * Dataframes are [polars](/api-client) by default; call `set_dataframe_backend("pandas")` once at session start if you prefer pandas. ## Learn more * [Simulations](/simulate/simulations) and [Studies](/simulate/studies) * [Universal Cycler Protocol](/simulate/universal-cycler-protocol) * [Simulation Python API](/simulate/api) # Process & upload cycler data Source: https://docs.ionworks.com/quickstarts/upload-cycler-data Convert a raw cycler export into the Ionworks standardized format and upload it as a time-series measurement with the Python SDK. Turn a raw cycler export (Arbin, Maccor, Neware, Basytec, BioLogic, Digatron, …) into the Ionworks standardized format and upload it as a measurement — all from Python. Install and authenticate first: `pip install ionworks-api` and set `IONWORKS_API_KEY` (and `IONWORKS_PROJECT_ID`). See the [Python API client](/api-client) page. ```python theme={null} from ionworks import Ionworks from ionworksdata import read client = Ionworks() # 1. Process the raw file into two standardized frames: # - time_series: one row per logged time point (Time [s], Voltage [V], # Current [A], Step count, ...) # - steps: one row per protocol step, with per-step summaries # (step/cycle indices, capacity, energy, min/max voltage, ...) time_series, steps = read.time_series_and_steps("arbin_export.csv", reader="arbin") # 2. Create-or-get the cell hierarchy: specification -> instance. cell_spec = client.cell_spec.create_or_get({"name": "LGM50"}) cell_instance = client.cell_instance.create_or_get(cell_spec.id, {"name": "SN-001"}) # 3. Upload as a time-series measurement (idempotent; strict validation on). measurement = client.cell_measurement.create_or_get( cell_instance.id, { "measurement": {"name": "cell_SN001_cycling"}, "time_series": time_series, "steps": steps, }, validate_strict=True, ) print(measurement.id) ``` **What's happening** * **`time_series` vs `steps`.** `time_series` is the raw signal trace — one row per logged time point. `steps` is a compact per-step summary — one row per protocol step — carrying step/cycle indices and derived quantities (capacity, energy, voltage extremes) that `read.time_series_and_steps` computes as it parses. Steps are what most analyses group and filter on. * `ionworksdata.read` has readers for the common cyclers; `read.detect(path)` auto-picks one. Prefer `read.time_series_and_steps` — it returns both frames and runs the full processing pipeline in one call. * **Current sign convention:** Ionworks treats **positive current as discharge**. Different cyclers use different conventions, so check your source and flip the sign if needed (`ionworksdata` provides `set_positive_current_for_discharge`) before uploading. * `create_or_get` is idempotent — re-running returns the existing spec/instance/measurement instead of duplicating it. A returned object (not the call merely completing) is the proof of success. ## Learn more * [Preparing data](/data/preparing-data) and [Reading cycler files](/data/reading) * [Uploading measurements](/data/uploading) and [Raw data](/data/raw-data) * [Standardized data format](/data/format) # Upload a model Source: https://docs.ionworks.com/quickstarts/upload-model Register a battery model with Ionworks — a built-in PyBaMM model or your own custom one — then bind it to parameters as a parameterized model. Register a model so you can parameterize and simulate it. A **model** is the structural definition (SPM, DFN, or your own equations); a [**parameterized model**](/build/parameterized-models) binds a model to a cell spec and parameter values — that's the runnable thing. Install and authenticate first: `pip install ionworks-api` and set `IONWORKS_API_KEY`. See the [Python API client](/api-client) page. ```python theme={null} import pybamm from ionworks import Ionworks client = Ionworks() # Option A — a built-in PyBaMM model. Pass the object directly (or a # {"type": "SPM"} config dict); it's stored as a standard model + options. model = client.model.create({ "name": "SPM with SEI", "description": "SPM + SEI growth submodel", "config": pybamm.lithium_ion.SPM(options={"SEI": "ec reaction limited"}), }) # Option B — your own pybamm.BaseModel subclass (new equations). Uploaded # as a custom model. custom_model = client.model.upload_custom( MyCustomModel(), # a pybamm.BaseModel instance (or serialized-JSON path) name="My Custom Model", chemistry="lithium_ion", # lithium_ion (default) | lithium_sulfur | ecm | generic ) # Bind a model to a cell spec + parameter values to get a runnable parameterized model. parameterized_model = client.parameterized_model.create_or_get("your-cell-spec-id", { "name": "LGM50 Chen2020", "model_id": model.id, "parameters": {...}, }) ``` **What's happening** * **Built-in vs custom.** `client.model.create` takes a **built-in** PyBaMM model — pass the object (`pybamm.lithium_ion.SPM(options=...)`) or a `{"type": "SPM"}` config dict — and stores it as a standard model with its options (`is_custom_model=False`). Reach for `client.model.upload_custom` only for a model class that isn't built in — your own `pybamm.BaseModel` subclass with new equations/submodels — which is stored as a custom model (`is_custom_model=True`). * Custom uploads need a `chemistry` tag up front — it drives simulation fix-ups and can't be reliably inferred (a Li-S model without it fails at simulation time). * A `Model` holds no parameter values and can't be simulated alone. Create a `ParameterizedModel` (against a `cell_spec_id`) to make it runnable — see [Run a simulation in a study](/quickstarts/run-in-study). ## Learn more * [Models](/build/models) and [Parameterized models](/build/parameterized-models) * [Projects & studies](/core-concepts/projects-studies) # Python API Source: https://docs.ionworks.com/simulate/api Run UCP simulations and submit parameterization pipelines programmatically with the ionworks-api Python client The [`ionworks-api`](https://github.com/ionworks/ionworks-api) Python package lets you run simulations and submit parameterization pipelines programmatically. For installation and authentication, see the [Python API client](/api-client) page. To save, validate, parse, or convert the protocols you run here, see the [Protocol API](/simulate/protocol-api) page. ## Running simulations Use `client.simulation` to run simulations. A simulation requires a [parameterized model](/build/parameterized-models) and a protocol in [UCP format](/simulate/universal-cycler-protocol). ### Single simulation ```python theme={null} response = client.simulation.protocol({ "parameterized_model": "your-parameterized-model-id", "protocol_experiment": { "protocol": """ global: initial_soc: 1 temperature: 25 steps: - Discharge: mode: C-rate value: 1 ends: - "Voltage < 2.5" """, "name": "1C Discharge", }, }) print(f"Simulation ID: {response.simulation_id}") print(f"Job ID: {response.job_id}") ``` You can also pass experiment parameters and design parameters: ```python theme={null} response = client.simulation.protocol({ "parameterized_model": "your-parameterized-model-id", "protocol_experiment": { "protocol": """ global: initial_soc: input["Initial SOC"] steps: - Discharge: mode: C-rate value: input["C-rate"] ends: - "Voltage < 2.5" """, "name": "Parameterized Discharge", }, "experiment_parameters": { "Initial SOC": 1.0, "C-rate": 0.5, }, "design_parameters": { "Positive electrode thickness [m]": 75e-6, }, }) ``` `design_parameters` is a single-simulation convenience field on `protocol()`. Pass a flat `dict[str, float]` of parameter overrides — the client translates them internally to a one-row discrete DOE before submission. Use it when you want to vary one or more design parameters for a single run without writing out the full DOE schema. In `protocol()`, `design_parameters` and `design_parameters_doe` are mutually exclusive, and any DOE you supply must resolve to exactly one simulation. Passing both, or a DOE that would expand to multiple simulations, raises `ValueError` — use [`protocol_batch`](#batch-simulations-with-design-of-experiments) for multi-simulation sweeps instead. ### Waiting for results Use `wait_for_completion` to poll until the simulation finishes. The method detects failed and canceled jobs immediately rather than waiting for the timeout. ```python theme={null} result = client.simulation.wait_for_completion( response.simulation_id, timeout=120, # seconds (default: 60) poll_interval=2, # seconds between polls (default: 2) verbose=True, # print status updates (default: True) ) ``` Set `raise_on_failure=False` to get the result dict instead of raising an exception when a simulation fails: ```python theme={null} result = client.simulation.wait_for_completion( response.simulation_id, raise_on_failure=False, ) ``` ### Batch simulations with design of experiments Run multiple simulations across a parameter sweep using `protocol_batch`: ```python theme={null} responses = client.simulation.protocol_batch({ "parameterized_model": "your-parameterized-model-id", "protocol_experiment": { "protocol": "...", "name": "C-rate Sweep", }, "design_parameters_doe": { "sampling": "grid", "rows": [ { "type": "discrete", "name": "Positive electrode thickness [m]", "values": [50e-6, 75e-6, 100e-6], }, ], }, }) # Wait for all simulations results = client.simulation.wait_for_completion( [r.simulation_id for r in responses], timeout=300, ) ``` Supported DOE row types: | Type | Fields | Description | | ---------- | ---------------------- | ---------------------------------------- | | `discrete` | `values` | Specific values to test | | `range` | `min`, `max`, `count` | Evenly spaced values between min and max | | `normal` | `mean`, `std`, `count` | Values sampled around the mean | Sampling strategies: `grid` (all combinations), `random`, `latin_hypercube`. ### Retrieving simulation data ```python theme={null} # List all simulations simulations = client.simulation.list() # Get a specific simulation simulation = client.simulation.get(simulation_id) # Get result data (time series, steps, metrics) result = client.simulation.get_result(simulation_id) ``` `get_result` returns a typed `SimulationResult` dataclass with three fields: | Field | Type | Description | | -------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- | | `result.time_series` | `DataFrame` | One row per time point; columns are signal names (e.g. `"Time [s]"`, `"Voltage [V]"`, `"Current [A]"`). | | `result.steps` | `DataFrame` | One row per protocol step (e.g. `"Step count"`, `"Step type"`, `"Duration [s]"`). | | `result.metrics` | `dict[str, Any]` | Scalar metrics computed over the run (e.g. cycle-level summaries). | `time_series` and `steps` are returned as polars DataFrames by default. Call [`set_dataframe_backend("pandas")`](/api-client#dataframe-backend) once at session start to receive pandas DataFrames instead. ```python theme={null} from ionworks import Ionworks client = Ionworks() simulation_id = "your-simulation-id" # e.g. response.simulation_id from a submitted run result = client.simulation.get_result(simulation_id) # Plot voltage vs time. With the default polars backend, convert to pandas first; # with the pandas backend (set_dataframe_backend("pandas")), use result.time_series directly. ts = result.time_series.to_pandas() # drop .to_pandas() on the pandas backend ts.plot(x="Time [s]", y="Voltage [V]") # Inspect protocol steps print(result.steps) # Read scalar metrics print(result.metrics) ``` `Discharge capacity [A.h]` and `Charge capacity [A.h]` in `time_series` reset to 0 at each step boundary. Use `"Step count"` to join `time_series` to `steps`, or accumulate per-step end values if you need a continuous cumulative capacity trace. ## Running pipelines Pipelines have moved to their own page. See the [Pipelines API how-to](/build/parameterize/api) for submitting pipelines and simple pipelines, polling them, reading results, and managing submissions. ## Managing studies Use `client.study` to create, list, update, and delete [studies](/simulate/studies). Studies are scoped to a project. All `client.study.*` methods accept `project_id` as an optional keyword argument. When omitted, they use the [default project](/api-client#default-project) configured on the client (or resolved from `IONWORKS_PROJECT_ID`). Pass `project_id=` explicitly to override on a per-call basis. ### Listing studies ```python theme={null} # Uses the default project from the client studies = client.study.list() for study in studies: print(f"{study.name} (ID: {study.id})") # Filter by name studies = client.study.list(name="Discharge") # Paginate results studies = client.study.list(limit=10, offset=0) # Override the project for a single call studies = client.study.list(project_id="other-project-id") ``` Supported filters: `name`, `name_exact`, `order_by`, `order`. ### Getting a study ```python theme={null} study = client.study.get("your-study-id") ``` ### Creating a study ```python theme={null} study = client.study.create({ "name": "1C Discharge Sweep", "description": "Comparing discharge curves across temperatures", }) ``` ### Updating a study ```python theme={null} study = client.study.update("your-study-id", { "name": "1C Discharge Sweep v2", }) ``` ### Assigning simulations and measurements ```python theme={null} # Assign a simulation to a study client.study.assign_simulation("your-study-id", "simulation-id") # Remove a simulation from a study client.study.remove_simulation("your-study-id", "simulation-id") # Assign a measurement to a study client.study.assign_measurement("your-study-id", "measurement-id") # List measurements in a study measurements = client.study.list_measurements("your-study-id") # Remove a measurement from a study client.study.remove_measurement("your-study-id", "measurement-id") ``` ### Deleting a study ```python theme={null} client.study.delete("your-study-id") ``` You can find the ID for any resource from the Ionworks Studio web app. The ID is displayed in the URL when you navigate to a resource's detail page. ## Next steps Learn about running simulations in Ionworks Studio. Save, validate, parse, and convert protocols with `client.protocol`. Full reference for the Universal Cycler Protocol format. Upload and manage cell data via the Python API. List and retrieve models and parameterized models. # Experiment Templates Source: https://docs.ionworks.com/simulate/experiment-templates Reference for the 9 built-in UCP experiment templates with adjustable parameters and computed metrics This page is a detailed reference for built-in templates. See [Protocols](/simulate/protocols) for the full guide on creating, managing, and using protocols. # Experiment Templates Ionworks provides 9 built-in experiment templates — pre-configured battery testing protocols that can be customized with different parameters. Each template defines a protocol using the [Universal Cycler Protocol (UCP)](/simulate/universal-cycler-protocol) format with adjustable variables to suit your needs. ## Template metrics Some experiment templates include built-in **metrics** — summary values automatically computed from simulation results. When you run a simulation using one of these templates, the metrics are calculated and displayed in the [Data View](/simulate/simulations#data-view) alongside the simulation inputs. Metrics use a declarative configuration format with these types: | Category | Types | Description | | --------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | | **Aggregation** | `First`, `Last`, `Mean`, `Minimum`, `Maximum`, `Sum` | Compute a single value from a time series variable | | **Crossing** | `SOC`, `Voltage`, `Time` | Find the value of a variable at a specific state of charge, voltage, or time | | **Composed** | `ComposedMetric` | Combine metrics with arithmetic operations (`add`, `sub`, `mul`, `div`, `abs`) | Metrics can also target a specific protocol step using the `step` field (0-indexed), which is useful for multi-step experiments like pulse resistance tests. The same metric type system is used in [Optimization Templates](/optimize/templates#metric-types) for defining optimization objectives and constraints. ## Available Templates ### 1. Constant Current Discharge **Description:** Discharges the battery at a constant C-rate until a voltage cutoff is reached. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Initial SOC \[%]** - Starting state of charge (default: 100%) * **C-rate** - Discharge rate relative to nominal capacity (default: 1C) * **Cut-off voltage \[V]** - Minimum voltage to stop discharge (default: V\_MIN) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: input["Initial SOC [%]"] steps: - Discharge: mode: C-rate value: input["C-rate"] resolution: time: 10 / input["C-rate"] ends: - Voltage < input["Cut-off voltage [V]"] ``` **Computed metrics:** | Metric | Description | | ------------------------------ | ---------------------------------------------- | | Capacity \[A.h] | Final discharge capacity | | Energy \[W\.h] | Final discharge energy | | Mean internal resistance \[mΩ] | Average internal resistance over the discharge | | Mean current \[A] | Absolute average current | | Mean power \[W] | Absolute average power | | Min/Max anode potential \[V] | Anode potential range during discharge | | Min/Max cathode potential \[V] | Cathode potential range during discharge | *** ### 2. Constant Current Charge **Description:** Charges the battery using a constant current (CC) phase followed by a constant voltage (CV) hold. This is the standard CC-CV charging protocol. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Initial SOC \[%]** - Starting state of charge (default: 0%) * **C-rate** - Charge rate during CC phase (default: 1C) * **Cut-off voltage \[V]** - Maximum voltage for CC/CV phases (default: V\_MAX) * **CV cut-off C-rate** - C-rate threshold to end CV phase (default: 0.02C) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: input["Initial SOC [%]"] steps: - Charge: mode: C-rate value: input["C-rate"] resolution: time: 10 / input["C-rate"] ends: - Voltage > input["Cut-off voltage [V]"] - Charge: mode: Voltage value: input["Cut-off voltage [V]"] ends: - C-rate < input["CV cut-off C-rate"] ``` **Computed metrics:** | Metric | Description | | ------------------------------- | ---------------------------------------------------------- | | Charge capacity \[A.h] | Final charge capacity | | Energy \[W\.h] | Final charge energy | | Mean internal resistance \[mΩ] | Average internal resistance | | Mean current \[A] | Average charging current | | Mean power \[W] | Absolute average power | | Min/Max anode potential \[V] | Anode potential range during charge | | Min/Max cathode potential \[V] | Cathode potential range during charge | | Charge time (10-80% SOC) \[min] | Time to charge from 10% to 80% state of charge, in minutes | *** ### 3. GITT (Galvanostatic Intermittent Titration Technique) **Description:** Alternates between current pulses and rest periods to measure quasi-equilibrium voltage as a function of state of charge. This technique separates kinetic and thermodynamic contributions to the cell voltage. This experiment will run until the voltage reaches the upper or lower voltage cut-off during the pulse phase. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Direction** - 'Charge' or 'Discharge' (default: 'Discharge') * **Pulse C-rate** - Current rate during active pulses (default: 0.1C) * **Pulse duration \[s]** - Length of each current pulse (default: 1800s / 30 min) * **Rest duration \[s]** - Length of each rest period (default: 1800s / 30 min) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: ifelse(input["Direction"] == "Charge", 0, 100) steps: - Control: set_variable: - name: VAR_IS_CHARGE eval: ifelse(input["Direction"] == "Charge", 1, 0) - name: VAR_VMAX eval: input["Upper voltage cut-off [V]"] - name: VAR_VMIN eval: input["Lower voltage cut-off [V]"] - Pulse Block: steps: - Direction[input["Direction"]]: mode: C-rate value: input["Pulse C-rate"] duration: input["Pulse duration [s]"] ends: - "Voltage > ifelse(VAR_IS_CHARGE == 1, VAR_VMAX, 1e9)": goto: Final Rest Block - "Voltage < ifelse(VAR_IS_CHARGE == 0, VAR_VMIN, -1e9)": goto: Final Rest Block - Rest: duration: input["Rest duration [s]"] repeat: 1000 - Final Rest Block: - Rest: duration: input["Rest duration [s]"] ``` *** ### 4. PITT (Potentiostatic Intermittent Titration Technique) **Description:** Steps the voltage in small increments with rest periods between steps. Measures current response to voltage changes to study electrode kinetics. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Starting voltage \[V]** - Initial cell voltage (default: V\_MIN) * **Final voltage \[V]** - Target cell voltage (default: V\_MAX) * **Voltage step \[V]** - Size of voltage increments (default: 0.1V) * **Pulse duration \[s]** - Hold time at each voltage (default: 900s / 15 min) * **Rest duration \[s]** - Rest time between voltage steps (default: 900s / 15 min) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: voltage initial_state_value: input["Starting voltage [V]"] steps: - Control: set_variable: - name: VAR_VOLTAGE eval: input["Starting voltage [V]"] - name: VAR_CHARGE eval: input["Final voltage [V]"] > input["Starting voltage [V]"] - name: VAR_END_CONDITION eval: input["Final voltage [V]"] - Pulse Block: steps: - Direction[ifelse(VAR_CHARGE == 1, "Charge", "Discharge")]: mode: Voltage value: VAR_VOLTAGE duration: input["Pulse duration [s]"] ends: - "Voltage > ifelse(VAR_CHARGE == 1, VAR_END_CONDITION, 1e9)": goto: Final Rest Block - "Voltage < ifelse(VAR_CHARGE == 0, VAR_END_CONDITION, -1e9)": goto: Final Rest Block - Rest: set_variable: - name: VAR_VOLTAGE eval: VAR_VOLTAGE + input["Voltage step [V]"] duration: input["Rest duration [s]"] repeat: 1000 - Final Rest Block: - Rest: duration: input["Rest duration [s]"] ``` *** ### 5. Pulse Resistance **Description:** Measures DC internal resistance (DCIR) using a rest-pulse-rest sequence. Quantifies the instantaneous voltage response to a current pulse. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Initial SOC \[%]** - State of charge for test (default: 50%) * **C-rate** - Magnitude of current pulse (default: 1C) * **Direction** - 'Charge' or 'Discharge' (default: 'Discharge') * **Duration \[s]** - Total pulse duration (default: 10s) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: input["Initial SOC [%]"] steps: - Rest: duration: input["Duration [s]"]/2 - Discharge: mode: C-rate value: input["C-rate"] duration: input["Duration [s]"] - Rest: duration: input["Duration [s]"]/2 ``` **Computed metrics:** | Metric | Description | | ------------------------- | -------------------------------------------------------------------------------- | | Pulse overpotential \[mV] | Voltage difference between rest and pulse end, in millivolts | | Pulse resistance \[mΩ] | DC internal resistance calculated as overpotential divided by mean pulse current | Pulse resistance metrics use step-level filtering to compare the voltage at the end of the initial rest period (step 0) with the voltage at the end of the current pulse (step 1). *** ### 6. Pseudo-OCV **Description:** Measures the voltage profile at a very low C-rate to approximate the open-circuit voltage (OCV) vs. SOC relationship. The slow rate minimizes polarization effects. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Direction** - 'Charge' or 'Discharge' (default: 'Discharge') * **C-rate** - Very slow rate to minimize polarization (default: 0.05C) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: ifelse(input["Direction"] == "Charge", 0, 100) steps: - Control: set_variable: - name: VAR_IS_CHARGE eval: input["Direction"] == "Charge" - name: VAR_VMAX eval: input["Upper voltage cut-off [V]"] - name: VAR_VMIN eval: input["Lower voltage cut-off [V]"] - Direction[input["Direction"]]: mode: C-rate value: input["C-rate"] ends: # End of charge/discharge - Voltage > ifelse(VAR_IS_CHARGE == 1, VAR_VMAX, 1e9) - Voltage < ifelse(VAR_IS_CHARGE == 0, VAR_VMIN, -1e9) ``` **Computed metrics:** | Metric | Description | | ------------------------------ | ------------------------------------ | | Capacity \[A.h] | Absolute step capacity | | Mean internal resistance \[mΩ] | Absolute average internal resistance | | Mean current \[A] | Absolute average current | *** ### 7. EIS (Electrochemical Impedance Spectroscopy) **Description:** Applies small AC voltage perturbations across a range of frequencies to measure the complex impedance of the cell. Maps out kinetic and transport processes. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **SOC \[%]** - State of charge for measurement (default: 50%) * **Lower frequency \[Hz]** - Minimum frequency (default: 0.0001 Hz) * **Upper frequency \[Hz]** - Maximum frequency (default: 10000 Hz) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: input["SOC [%]"] steps: - EIS: lower_frequency: input["Lower frequency [Hz]"] upper_frequency: input["Upper frequency [Hz]"] ``` *** ### 8. Cyclic Voltammetry **Description:** Sweeps the cell voltage linearly between upper and lower limits at a controlled scan rate. Measures current response to identify redox reactions. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Scan rate \[mV/s]** - Rate of voltage sweep (default: 0.1 mV/s) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: voltage initial_state_value: input["Lower voltage cut-off [V]"] steps: - Control: set_variable: - name: VAR_SCAN_RATE_VS eval: input["Scan rate [mV/s]"] / 1000 - Charge: mode: Voltage value: input["Lower voltage cut-off [V]"] + VAR_SCAN_RATE_VS * t ends: - Voltage > input["Upper voltage cut-off [V]"] - Discharge: mode: Voltage value: input["Upper voltage cut-off [V]"] - VAR_SCAN_RATE_VS * t ends: - Voltage < input["Lower voltage cut-off [V]"] ``` *** ### 9. Cycle Aging **Description:** Repeated charge/discharge cycles with capacity fade monitoring. Uses CCCV charge (constant current to voltage limit, then constant voltage to C-rate cutoff) and CC discharge with dual termination (voltage cutoff or depth-of-discharge capacity limit). Tracks capacity retention and can stop early when capacity falls below a specified percentage of nominal. **Adjustable Parameters:** * **Temperature \[°C]** - Operating temperature (default: 25°C) * **Nominal capacity \[A.h]** - Cell capacity for C-rate and DOD calculations (default: 5.0 A.h) * **Charge C-rate** - C-rate during CC charge phase (default: 1C) * **Discharge C-rate** - C-rate during discharge (default: 1C) * **Depth of discharge \[%]** - Target DOD per cycle as % of nominal capacity (default: 100%) * **Discharge voltage cutoff \[V]** - Minimum voltage to stop discharge (default: V\_MIN) * **Charge voltage \[V]** - Maximum voltage for CC/CV charge (default: V\_MAX) * **Charge C-rate cutoff** - C-rate threshold to end CV phase (default: 0.05C) * **Post charge rest time \[s]** - Rest after charge (default: 600 s) * **Post discharge rest time \[s]** - Rest after discharge (default: 600 s) * **Number of cycles** - Maximum charge/discharge cycles (default: 100) * **End capacity \[%]** - Stop cycling when capacity falls below this % of nominal (default: 80%) ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: 50 steps: - Control: set_variable: - name: VAR_VMAX eval: input["Charge voltage [V]"] - name: VAR_VMIN eval: input["Discharge voltage cutoff [V]"] - name: VAR_NOMINAL_CAPACITY eval: input["Nominal capacity [A.h]"] - name: VAR_DOD_FRACTION eval: input["Depth of discharge [%]"] / 100 - name: VAR_END_CAPACITY_RATIO eval: input["End capacity [%]"] / 100 - name: VAR_CURRENT_CAPACITY eval: "0" - name: VAR_CAPACITY_RATIO eval: "1.0" - Control: set_variable: - name: VAR_DOD_CAPACITY_LIMIT eval: VAR_DOD_FRACTION * VAR_NOMINAL_CAPACITY - Cycle Block: repeat: input["Number of cycles"] steps: - Increment cycle number - Charge: mode: C-rate value: input["Charge C-rate"] resolution: time: 10 / input["Charge C-rate"] ends: - Voltage > VAR_VMAX - Charge: mode: Voltage value: VAR_VMAX resolution: time: 10 / input["Charge C-rate"] ends: - C-rate < input["Charge C-rate cutoff"] - Rest: duration: input["Post charge rest time [s]"] - Discharge: mode: C-rate value: input["Discharge C-rate"] resolution: time: 10 / input["Discharge C-rate"] ends: - Voltage < VAR_VMIN - Capacity > VAR_DOD_CAPACITY_LIMIT set_variable: - name: VAR_CURRENT_CAPACITY eval: abs(last(Capacity)) - Control: set_variable: - name: VAR_CAPACITY_RATIO eval: VAR_CURRENT_CAPACITY / VAR_NOMINAL_CAPACITY - Rest: duration: input["Post discharge rest time [s]"] ends: - type: Variable expression: VAR_CAPACITY_RATIO < VAR_END_CAPACITY_RATIO goto: End Block - End Block: - Rest: duration: 1 ``` **Computed metrics:** | Metric | Description | | ------------------------------- | ------------------------------------------------------------- | | Total cycles | Number of completed charge/discharge cycles | | Initial capacity \[A.h] | Absolute discharge capacity from the first cycle | | Final capacity \[A.h] | Absolute discharge capacity from the last cycle | | Capacity retention \[%] | Ratio of final to initial capacity, expressed as a percentage | | Total energy throughput \[W\.h] | Sum of discharge and charge energy over all cycles | | Total charge throughput \[A.h] | Sum of discharge and charge capacity over all cycles | *** ## Parameter Types ### Standard Parameters Most templates accept these common parameters: * **Temperature \[°C]**: Operating temperature * **Initial SOC \[%]**: Starting state of charge (0-100%) * **C-rate**: Current normalized by nominal capacity (e.g., 1C = full capacity in 1 hour) ### Voltage References Some parameters can reference cell-specific voltages: * **V\_MIN**: Minimum safe voltage for the cell model * **V\_MAX**: Maximum safe voltage for the cell model These are automatically replaced with values from your selected cell model. ### Direction For bidirectional tests, specify 'Charge' or 'Discharge' to set the current direction. *** ## Using Experiment Templates ### In Simulations 1. Select a cell and model 2. Choose an experiment template from the "Start from existing protocol" dropdown 3. Adjust parameters as needed 4. Run the simulation ### Creating Studies You can run multiple experiments with different parameters in a study to: * Compare performance at different temperatures * Evaluate different C-rates * Map out behavior across SOC range ## Next Steps * [Create your own protocols](/simulate/protocols) with custom parameters * Learn more about [Projects and Studies](/core-concepts/projects-studies) * Explore [Simulations](/simulate/simulations) * Understand the [Universal Cycler Protocol](/simulate/universal-cycler-protocol) format # Protocol API Source: https://docs.ionworks.com/simulate/protocol-api Save, validate, parse, and convert UCP protocols programmatically with the ionworks-api Python client Use `client.protocol` to manage [UCP protocols](/simulate/universal-cycler-protocol) programmatically: save them to a project, validate them, parse a vendor cycler file into UCP, and convert UCP back out to a cycler's native format. For installation and authentication, see the [Python API client](/api-client) page. To run a saved protocol, see the [Python API](/simulate/api) page. ## Saving a protocol to a project Saved protocols are project-scoped and content-addressed: two protocols with the same body are the same record, so saving one that already exists returns the existing record instead of creating a duplicate. ```python theme={null} protocol = client.protocol.create( name="1C discharge", protocol=ucp_yaml, description="Baseline discharge to 2.5 V", ) print(protocol.id) ``` Use `create_or_get` when you do not care whether this is the first time: ```python theme={null} protocol = client.protocol.create_or_get(name="1C discharge", protocol=ucp_yaml) ``` ## Listing and finding saved protocols ```python theme={null} page = client.protocol.list(name="discharge", order_by="created_at", order="desc") print(page.total) # every matching protocol, not just this page protocol = client.protocol.find_by_name("1C discharge") ``` `list` also accepts `name_exact`, `created_by_email`, and the `created_after` / `created_before` / `updated_after` / `updated_before` date filters, plus `limit` and `offset`. Filtering and ordering are applied by the database, so `total` counts every match. Protocols are deduplicated on their body, not their name, so a project can hold several protocols sharing one name. `find_by_name` raises `ValueError` in that case rather than returning an arbitrary one — use `list` when you need to choose between them. ## Reading, renaming, and deleting ```python theme={null} print(client.protocol.human_readable(protocol.id)) # readable step-by-step text print(client.protocol.source_protocol(protocol.id)) # original text as saved client.protocol.update(protocol.id, name="1C discharge (baseline)") client.protocol.delete(protocol.id) ``` ## Parsing a vendor protocol file into UCP `parse_file` turns a commercial cycler's protocol file into UCP — the reverse of [converting UCP to a vendor file](#converting-ucp-to-a-vendor-protocol-file). ```python theme={null} parsed = client.protocol.parse_file("path/to/schedule.sdu") print(parsed.ucp) # the parsed protocol as UCP YAML print(parsed.cycler_type) # detected vendor, when identifiable ``` A parsed protocol is not saved — pass `parsed.ucp` to `create` to store it. `parsed.nominal_capacity_ah` carries the cell rating the file declared, when it declares one — BioLogic formats do. It is `None` for a format that records no rating, so check before relying on it. Feed it straight back to [`convert`](#converting-ucp-to-a-vendor-protocol-file), which needs a rating to re-express C-rate steps for a `neware` target. Drive cycles and subroutines the file referenced but did not embed are reported separately: those the parser recovered appear in `available_drive_cycles` / `available_subroutines`, and those still outstanding in `required_drive_cycles` / `required_subroutines`. Supply the required ones before simulating. If the file parsed only partially, `parsed.error` is set and `parsed.ucp` may be incomplete. ## Validating a protocol ```python theme={null} result = client.protocol.validate(""" global: initial_soc: 1 temperature: 25 steps: - Discharge: mode: C-rate value: 1 ends: - "Voltage < 2.5" """) print(result["valid"]) # True or False if not result["valid"]: print(result["error"]) ``` ## Finding input references Find `input[...]` placeholders in a protocol string, useful for building experiment parameter forms. ```python theme={null} refs = client.protocol.find_input_references(""" global: initial_soc: input["Initial SOC"] steps: - Discharge: mode: C-rate value: input["C-rate"] """) print(refs) # ["Initial SOC", "C-rate"] ``` ## Converting UCP to a vendor protocol file Use `client.protocol.convert` to translate a UCP YAML protocol into the native file format used by a commercial cycler. This is the reverse of the [commercial protocol upload flow](/simulate/simulating-commercial-protocols) — start from a protocol designed in Ionworks and produce a file you can run on hardware. Supported targets: `maccor`, `arbin`, `arbin_sdx`, `neware`, `biologic_bttest`, `novonix`. `arbin` writes the MITS Pro ≤7 `.sdu` dialect; `arbin_sdx` writes the MITS Pro 8+ `.sdx` dialect. Both are first-class — pick whichever matches your MITS Pro version. For the `neware` target, pass `nominal_capacity_ah` (the rated cell capacity in amp-hours) whenever the protocol uses C-rate steps or cutoffs. Neware sets current in absolute mA and has no C-rate mode, so the rate cannot be resolved without a capacity. The other targets express C-rate natively and ignore this argument. Pass `verify=True` to ask the server to round-trip the converted file back through its parser and reject the conversion if the protocol no longer means the same thing — a dropped loop count, goto, or safety bound. It's most reliable for the `biologic_bttest` and `novonix` targets today; the `maccor`, `arbin`, and `neware` writers can still report a few benign artifacts as differences, so enabling it there can reject an otherwise valid conversion. ```python theme={null} result = client.protocol.convert( protocol=""" global: initial_temperature: 25 initial_state_type: soc_percentage initial_state_value: 100 steps: - CC Charge: - Charge: mode: C-rate value: 1 ends: - "Voltage > 4.2" - CV Charge: - Charge: mode: Voltage value: 4.2 ends: - "Current < 0.05" - Discharge: - Discharge: mode: C-rate value: 1 ends: - "Voltage < 2.7" """, target="maccor", ) # Inspect the converted protocol as text print(result.text()) # Or write every output file (primary protocol plus any sidecars) to disk paths = result.save("./converted") print(paths) # [PosixPath('converted/protocol.000'), ...] ``` `ConvertResult` exposes: * `primary_bytes` — raw bytes of the primary protocol file. * `text(encoding="utf-8")` — decode `primary_bytes` to a string. * `save(dir)` — write every output file (primary plus any sidecars) into `dir` and return the list of paths written. Maccor protocols that include drive-cycle steps produce one or more `.MWF` waveform files alongside the primary `.000` file. Use `result.save(dir)` so every sidecar lands next to the primary protocol — handling only `primary_bytes` drops the waveform files and the protocol will not run on the cycler. Some UCP features map cleanly across all formats, but each vendor has its own syntax and limitations (see [Differences between commercial protocols](/simulate/simulating-commercial-protocols#differences-between-commercial-protocols)). If a UCP construct can't be expressed in the target format, the conversion returns an error naming the unsupported step (see [Export-time validation](/simulate/simulating-commercial-protocols#export-time-validation) for the specific features each target format rejects). Validate the output by reuploading it through the [commercial protocol flow](/simulate/simulating-commercial-protocols) before running it on a real cycler. You can find the ID for any resource from the Ionworks Studio web app. The ID is displayed in the URL when you navigate to a resource's detail page. ## Next steps The UCP format reference — steps, modes, and end conditions. Run simulations and pipelines against a saved protocol. Upload a vendor protocol file and simulate it directly. Build a protocol visually in Ionworks Studio. # Protocol Builder Source: https://docs.ionworks.com/simulate/protocol-builder Visually create UCP cycling protocols with steps, safety limits, and live YAML preview—no hand-written YAML required The Protocol Builder is a visual editor for creating [Universal Cycler Protocol](/simulate/universal-cycler-protocol) (UCP) files directly in Ionworks Studio. You can add and configure steps, set global parameters and safety limits, and preview the generated YAML in real time — all without writing YAML by hand. ## Getting started Open the Protocol Builder by clicking **New Protocol** on the **Protocols** page of your project in Studio. The editor has two panels: * **Left panel** — step editor with global configuration and the step list * **Right panel** — live YAML preview that updates as you make changes ## Global configuration The collapsible **Global Settings** section at the top of the editor lets you configure protocol-wide defaults: * **Initial state** — set the starting state of the cell as either an SOC percentage (0–100%) or a voltage * **Initial temperature** — ambient temperature in °C * **Resolution** — time, voltage, and current resolution for the simulation output ### Safety limits You can define global safety limits that terminate any step if breached: | Limit | Description | | --------------------- | --------------------------------------- | | Voltage min / max | Lower and upper voltage bounds (V) | | Temperature min / max | Lower and upper temperature bounds (°C) | | Charge current max | Maximum charge current (A) | | Discharge current max | Maximum discharge current (A) | These correspond to the `safety_limits` section in the [UCP format](/simulate/universal-cycler-protocol#safety-limits). ## Adding steps Click **Add Step** to open a categorized menu of available step types: **Common steps:** * **Charge** — constant current, voltage, power, or C-rate charge * **Discharge** — constant current, voltage, power, or C-rate discharge * **Rest** — open-circuit rest period * **Drive** — drive cycle from a CSV file **Advanced steps:** * **EIS** — electrochemical impedance spectroscopy sweep * **Waveform** — custom waveform step * **Ambient temperature** — change the ambient temperature mid-protocol * **Step block** — group steps into a named, repeatable block **Control flow:** * **Control** — set variables or add programmatic logic * **Subroutine** — call a reusable step sequence * **Increment cycle number** — advance the cycle counter * **End / Pause** — terminate the protocol Each step opens in a slide-out editor where you configure its parameters, control mode, and end conditions. ## Editing steps Click any step card to open its editor. Depending on the step type, you can configure: * **Mode** — the control mode (C-rate, Current, Power, or Voltage) * **Value** — the setpoint, either as a fixed number or a parameterized expression using `input["..."]` syntax * **Duration** — maximum step duration in seconds * **End conditions** — one or more termination conditions (voltage, current, C-rate, capacity, temperature, or duration thresholds) with support for derivative-based termination (`d/dt`) * **Variables** — define `set_variable` entries that compute values after the step completes * **Notes** — free-text annotation Toggle between **Fixed** and **Input** for step values and end conditions to create parameterized protocols. Input expressions use the `input["name"]` syntax described in the [UCP reference](/simulate/universal-cycler-protocol#parameterized-steps-with-input). ### Drive cycle steps For Drive steps, you can upload a CSV file containing a time-current or time-power profile. The builder shows a chart preview of the uploaded waveform and automatically sets the step name from the filename. ### End conditions Each end condition specifies a variable, an operator (`<` or `>`), and a threshold value. You can also configure: * **Derivative termination** — terminate based on the rate of change (`d/dt`) of a variable * **Goto** — jump to a named step block when the condition is met ## Organizing steps The Protocol Builder provides several tools for managing step order and structure: * **Move up / down** — reorder steps within the list * **Duplicate** — create a copy of a step (or an entire block with all nested steps) * **Wrap in block** — select multiple steps and group them into a new step block with an optional `repeat` count * **Move to block** — move selected steps into an existing named block * **Move out of block** — promote steps from inside a block to the parent level Step blocks support arbitrary nesting, so you can create complex looping structures by placing blocks inside other blocks. ## YAML preview and import/export The right panel shows the generated YAML in real time. From this panel you can: * **Copy to clipboard** — copy the full YAML to your clipboard * **Download** — save the protocol as a `.yaml` file * **Upload** — import an existing `.yaml` or `.yml` file to load it into the editor * **Edit manually** — switch to a text editor to modify the YAML directly, then apply your changes back to the visual editor Uploaded and manually edited YAML is validated before being applied. If the YAML contains errors, you will see a validation message and the changes will not be applied until the errors are resolved. ## Validation The Protocol Builder validates your protocol at two levels: 1. **Frontend validation** — checks structure, required fields, and common mistakes (such as duplicate block names or missing end conditions) as you edit 2. **Backend validation** — sends the protocol to the server for deeper checks, including compatibility with the simulation engine Validation errors appear as alerts between the global configuration and the step list. Fix any reported issues before using the protocol in a simulation. ## Next steps In the test scheduler, describe the test in plain English instead of using the visual editor. Full reference for the Universal Cycler Protocol YAML format. Use built-in templates for common battery testing protocols. # Protocols Source: https://docs.ionworks.com/simulate/protocols Create, upload, and reuse cycling protocols in UCP, Arbin, Maccor, Neware, BioLogic, and Novonix formats for battery simulations Protocols define the sequence of electrochemical steps that make up an experiment — charges, discharges, rests, EIS measurements, and more. Ionworks lets you create protocols from scratch, upload them from cycler software, or start from built-in templates, and then reuse them across simulations. **Which page do I need?** * **This page** — manage saved protocols: create, upload, clone, and configure parameterized inputs. * [**AI protocol authoring**](/operate/ai-protocol-authoring) — describe a test in plain English and get back a validated UCP protocol. * [**Protocol Builder**](/simulate/protocol-builder) — build a UCP protocol visually, step by step, without writing YAML. * [**Universal Cycler Protocol**](/simulate/universal-cycler-protocol) — the full reference for the UCP YAML file format. * [**Simulating Commercial Protocols**](/simulate/simulating-commercial-protocols) — upload a Maccor, Neware, Arbin, BioLogic, or Novonix file and simulate it to verify its behavior. ## Protocols page Navigate to **Protocols** in the sidebar to see all saved protocols in your project. The list shows each protocol's name, when it was created, and whether it has configurable input parameters. Protocols are scoped to a [project](/core-concepts/projects-studies). Each project gets its own copy of the built-in [experiment templates](/simulate/experiment-templates) when it is created, and any protocol you create, upload, or clone belongs to the project you are currently working in. Switching projects switches which protocols you see. From here you can: * **Create** a new protocol * **View** a protocol's source, parsed representation, and input parameters * **Clone** a protocol to create a modified version * **Edit** a protocol's name and input parameter configuration ## Creating a protocol Click **New Protocol** to open the protocol editor. You have several ways to define a protocol: ### Type or paste Enter protocol text directly in the source editor on the left. Ionworks supports multiple cycler formats: * **UCP** (Universal Cycler Protocol) — Ionworks' native YAML format * **Arbin** — Arbin schedule files * **Maccor** — Maccor procedure files (.000 XML or CSV format) * **Neware** — Neware protocol exports * **BioLogic** — BioLogic protocol exports (.mps, .bttest) * **Novonix** — Novonix protocol files (.pro2) The editor automatically detects the format and parses it. The right side shows the parsed result in UCP (YAML) and human-readable formats so you can verify the interpretation. ### Upload a file Click **Upload** to load a protocol file from your computer. The file is read, its format detected, and the content placed into the source editor. The protocol name is pre-filled from the filename. ### Interactive builder Click **Interactive Builder** to open a visual step-by-step protocol editor. You can add charge, discharge, rest, and other step types, configure their parameters, and set termination conditions. The builder generates UCP YAML which is placed into the source editor when you apply. ### Describe in plain English You can have a protocol written for you from a plain-English description of the test — for example "1C/1C cycling for 200 cycles between 4.2 V and 2.5 V". This is available today on the **Test scheduler → Request test** form rather than on this page, and it can also be driven directly through the `POST /protocols/generate` endpoint. See [AI protocol authoring](/operate/ai-protocol-authoring) for prompt tips, clarifying questions, and the API contract. ### Start from an existing protocol Use the **Start from existing protocol** dropdown to select a saved protocol as a starting point. This pre-fills the editor with the protocol's source text, which you can then modify. ## Parameterized inputs Protocols can use `input["..."]` expressions to define parameters that are configurable at simulation time. For example: ```yaml theme={null} global: initial_temperature: input["Temperature [°C]"] initial_state_type: soc_percentage initial_state_value: input["Initial SOC [%]"] steps: - Discharge: mode: C-rate value: input["C-rate"] ends: - Voltage < input["Cut-off voltage [V]"] ``` When you enter or upload a protocol, Ionworks automatically detects these `input["..."]` references and displays them below the editor. For each detected input you can configure: * **Type** — whether the input is a **numeric value** (e.g. C-rate, temperature) or a **direction selector** (Charge / Discharge) * **Default value** — the value to pre-fill when this protocol is used in a simulation These settings are saved as the protocol's **parameters schema**. When someone uses this protocol to run a simulation, the schema generates the appropriate input form with the configured defaults. ### Voltage cut-off inputs Inputs named `Lower voltage cut-off [V]` and `Upper voltage cut-off [V]` are special — they are automatically provided by the cell model's voltage limits and do not appear as user-configurable fields. ### Special values Default values can reference cell-specific properties: * **V\_MIN** — the cell model's minimum safe voltage * **V\_MAX** — the cell model's maximum safe voltage These are automatically resolved to the correct values based on the selected cell when running a simulation. ## Built-in templates Ionworks provides 9 pre-configured experiment templates for common battery testing protocols. Each template defines a protocol with adjustable parameters that you can use directly or as a starting point for your own protocols. Discharge at a fixed C-rate until voltage cutoff Standard CC-CV charging protocol Galvanostatic intermittent titration technique Potentiostatic intermittent titration technique DC internal resistance (DCIR) measurement Open-circuit voltage approximation at low C-rate Electrochemical impedance spectroscopy Voltage sweep to identify redox reactions Repeated charge/discharge with capacity fade monitoring See the [full template reference](/simulate/experiment-templates) for detailed parameters and UCP protocol definitions. ## Using protocols in simulations When [running a simulation](/simulate/simulations), you can provide a protocol in several ways: 1. **Select a saved protocol** from the "Start from existing protocol" dropdown — this includes both the [built-in templates](#built-in-templates) and your custom protocols 2. **Paste or type** a protocol directly in the editor 3. **Upload** a protocol file 4. **Use the Interactive Builder** to create one visually If the protocol has detected input parameters, they appear as configurable fields in the simulation setup. You can enter values (or comma-separated lists for parameter sweeps) for each input. If you enter a custom protocol and choose to name it, it is saved for future reuse and appears in the protocols list and the "Start from existing protocol" dropdown. ## Next steps * Learn about the [Universal Cycler Protocol](/simulate/universal-cycler-protocol) format * See how to simulate [commercial cycler protocols](/simulate/simulating-commercial-protocols) * Run [simulations](/simulate/simulations) with your protocols # Simulating Commercial Protocols Source: https://docs.ionworks.com/simulate/simulating-commercial-protocols Upload protocol files from Maccor, Neware, Arbin, BioLogic, and Novonix cyclers and run physics-based simulations Have you ever started a multi-day battery test, only to realize later that the protocol you used was incorrect? The Protocol Simulator helps you prevent this by allowing you to upload a protocol file and run a quick physics-based simulation to verify its behavior before you start a real-world experiment. The Protocol Simulator lives inside a [project](/core-concepts/projects-studies) — open a project and select **Simulate → Protocol Simulator** in the sidebar to get started. You launch and run it from within a project, and it can select parameterized models from that project. Each project has its own set of protocols, including its own copies of the standard ones, so editing a protocol in one project never affects another. Quick models are shared across your organization and remain available to other projects. ## How It Works The process is simple: upload your protocol, configure your cell model, and run the simulation. 1. **Upload:** Upload your protocol file in the raw format (e.g. XML or JSON). The system automatically detects the format and parses it. 2. **Configure:** Pick a cell model — either a **Quick model** built on the fly from a chemistry preset, or a full [parameterized model](/build/parameterized-models) from your project. 3. **Simulate:** Run the simulation and analyze the results. ## Step 1: Upload Your Protocol File You can start by uploading a protocol file from your computer. We currently support files from: * Arbin * BioLogic (.mps, .bttest) * Maccor (.xml, .csv) * Neware * Novonix (.pro2) * PyBaMM experiment strings (.txt) Once you upload a file, the system immediately parses it and displays the translated steps. Gamry `.dta` files are not protocol files and cannot be uploaded here. To import EIS measurement data from Gamry instruments, use the [`ionworksdata`](https://data.docs.ionworks.com/) library — see [Data format](/data/format#eis-and-impedance-data) for details. ### Required Additional Files Some protocols reference external files for complex steps, such as custom drive cycle waveforms or reusable subroutines. If your protocol requires such files, the simulator will detect this and prompt you to upload them. For Arbin schedules this includes `.subsdx` subschedule files referenced by `SubSchedule` steps. Each subschedule maps to a UCP [`Subroutine`](/simulate/universal-cycler-protocol#subroutines) step and can itself reference further subschedules — those nested requirements are surfaced once you attach the parent file, so the upload prompt cascades one level per attachment until every dependency is resolved. ## Step 2: Review and Configure After parsing, you can review the protocol and set up the simulation. ### Protocol Steps The simulator displays the parsed protocol in three different formats, accessible via tabs: * **Human-Readable:** A simplified, easy-to-read summary of the steps in your protocol. * **UCP (YAML):** The full protocol translated into our [Universal Cycler Protocol](/simulate/universal-cycler-protocol) format. This shows the detailed underlying structure that will be executed. * **Raw:** The raw text content of the file you uploaded. ### Cell Configuration To run a meaningful simulation, you need to tell the simulator which cell model to use. You have two options: * **Quick model** — build a cell model on the fly from a chemistry preset and a few basic inputs. Best for a fast sanity check when you don't yet have a fitted model for the cell. * **Project parameterized model** — select an existing [parameterized model](/build/parameterized-models) from the current project. Best when you want the protocol to run against the same physics-based model you use elsewhere in the project. #### Quick model The Quick model configures the underlying [Equivalent Circuit Model (ECM)](/build/models#ionworks-models) with one RC pair, using OCV and resistance parameters matched to a chemistry preset. Use it when you want to validate a protocol against a representative cell without first fitting a full parameterized model. * **Chemistry:** Select the cell chemistry from a list of pre-configured options. Full-cell chemistries (e.g., `NMC/Graphite`, `LFP/Graphite`) and Li-metal half cells (e.g., `NMC/Li metal`, `Graphite/Li metal`, `LFP/Li metal`) are available. This determines the OCV curve, anode/cathode open-circuit potentials, and resistance values used by the model. * **Cell capacity (Ah):** The nominal capacity of your cell. The ECM parameters are scaled to match this capacity. * **Resistance scale (%):** Adjusts the model's internal resistance relative to the default value for the selected chemistry. The default is `100%` (no change). For example, set this to `200` to double the resistance or `50` to halve it. Useful for approximating cells with higher or lower impedance than the chemistry default. * **Initial SOC (%):** The State of Charge of the cell at the beginning of the simulation. * **Temperature (°C):** The ambient temperature for the simulation. #### Project parameterized model If you already have a [parameterized model](/build/parameterized-models) in the current project, pick it from the model selector instead of building a Quick model. The simulator runs the uploaded protocol against that model with its full parameter set, so results are directly comparable to other simulations in the project. Only Initial SOC and Temperature need to be supplied on top of the model. Check carefully that the cell model matches the cell for which the protocol was designed. If the model is a poor fit — wrong chemistry, wrong capacity, or wrong voltage window — the simulation may fail to run or produce misleading results. ### Advanced configuration Under the **Advanced** section, you can configure optional rules that modify simulation behavior at runtime. #### Termination conditions Termination conditions let you stop a simulation early when a variable reaches a target value. This is useful for long cycling protocols where you only need to simulate a limited number of cycles or a specific amount of time. Each condition specifies a **variable**, a **comparison operator** (`==`, `!=`, `>`, `<`, `>=`, `<=`), and a **value**. The simulation stops as soon as any condition is met. The variable dropdown is organized into two groups: * **Built-in** — variables automatically provided by the simulation engine regardless of the protocol * **Protocol variables** — variables defined in and extracted from the parsed protocol ##### Total time **Total time** is a built-in variable that tracks the cumulative elapsed simulation time in seconds. Use it to cap how long a simulation runs, independent of the protocol's own step logic. When you select **Total time**, a unit picker appears next to the value field so you can enter the threshold in **seconds**, **minutes**, **hours**, or **days**. The value is automatically converted to seconds for the simulation engine. For example, to stop a long cycling protocol after 2 hours, add a termination condition where **Total time >= 2 hours**. If the protocol defines its own variable named `total_time`, the protocol's variable takes precedence and the built-in value is not injected. ##### Protocol variables Protocol variables are extracted from your uploaded protocol file. For example, to stop an Arbin protocol after 3 cycles, you can add a condition where `PV_CHAN_Cycle_Index >= 4`. Arbin increments `PV_CHAN_Cycle_Index` at the start of each cycle, so the index reaches 4 only when the 4th cycle begins — meaning 3 full cycles have already completed. When a termination condition triggers, the reason is displayed in the simulation metrics using the human-readable form (e.g., "Early termination reason: Total time >= 2 hours"). #### Variable callback rules Variable callback rules let you dynamically update protocol variables during the simulation based on conditions. Each rule specifies a condition (variable, operator, value) and a set of variable updates to apply when the condition is met. For example, you could create a rule that increases the C-rate after a certain number of cycles: when `PV_CHAN_Cycle_Index >= 3`, update `Current(A)` to a higher value. This lets you simulate multi-phase protocols where charging or discharging parameters change at specific points during cycling. ## Step 3: Run Simulation & Analyze Results Once everything is configured, click **Run Simulation**. The simulator will execute the protocol against the configured cell model. You can cancel a running simulation at any time by clicking the **Cancel** button on the simulation page. This is useful for long-running protocols where you can already see the results you need. ### Simulation results #### Plots The primary output is an interactive plot of your simulation data over time. By default, **Voltage** and **Current** are shown, but you can configure exactly which variables appear. Click **Configure Plot** to open the plot settings drawer. Under **Time Series**, toggle any of the available variables on or off: | Variable | Unit | Description | | ------------------------- | ---- | ------------------------------------- | | Voltage | V | Cell terminal voltage | | Current | A | Applied current | | Temperature | °C | Cell temperature (when available) | | Charge capacity | Ah | Cumulative charge capacity | | Discharge capacity | Ah | Cumulative discharge capacity | | State of charge | % | Cell state of charge (when available) | | Step count | | Overall step index | | Cycle count | | Current cycle number | | Step count (within cycle) | | Step number within the current cycle | Each enabled variable is displayed in its own subplot, stacked vertically and sharing a common time axis. Charge capacity and discharge capacity are grouped into a single subplot for easy comparison. If your protocol defines numeric variables (e.g., C-rate, temperature setpoints), you can also plot these as time series. In the **Configure Plot** drawer, use the **Additional Variables** dropdown to select any numeric protocol variable. The selected variables appear as additional subplots alongside the built-in time series. #### Key metrics Below the plot, you will find key performance indicators calculated from the simulation, including: * Total Time * Charge Throughput (Ah) * Energy Throughput (Wh) * Early termination reason (if a termination condition was triggered) * Stop reason (set to `Reached pause step` or `Reached end step` when the protocol's control flow stopped on an auxiliary step instead of running to the end) #### Fullscreen mode Click the fullscreen icon in the top-right corner of the plot to expand it to fill the screen. The fullscreen view includes the same **Configure Plot** button, so you can adjust which variables are shown without leaving fullscreen. #### Download CSV Click **Download CSV** next to **Configure Plot** to export the full simulation time series as a CSV file (`simulation_data.csv`). The download always contains the complete simulation, even when you've zoomed into a window on the plot. The file includes every time-series variable produced by the simulation (Voltage, Current, Temperature, capacities, Step count, Cycle count, and any additional protocol variables), plus step-level columns expanded to match each time point. Use it to bring results into Excel, pandas, MATLAB, or your own analysis tooling without re-running the simulation. By simulating your protocols, you can catch errors, validate your experimental design, and gain confidence before committing time and resources to a real test. ## Exporting UCP to a vendor protocol file The conversion also runs in reverse: you can take a protocol authored in [UCP](/simulate/universal-cycler-protocol) and export it as a native file for Maccor, Neware, Arbin, BioLogic, or Novonix. This is useful when you've designed and validated a protocol in Ionworks and want to run it on physical hardware. Use the [`client.protocol.convert`](/simulate/protocol-api#converting-ucp-to-a-vendor-protocol-file) method in the Python API to perform the conversion programmatically. Because vendor formats vary in expressiveness, not every UCP construct round-trips perfectly. See [Differences between commercial protocols](#differences-between-commercial-protocols) below for the major gotchas, and [Export-time validation](#export-time-validation) for the specific UCP features each target format rejects upfront. We recommend re-importing the converted file using the upload flow above to confirm it parses back into the steps you expect before running it on a cycler. ### Export-time validation When you convert a UCP protocol to Arbin or Maccor, the converter walks the protocol first and rejects features that can't be faithfully represented in the target format. You get a `ValueError` naming the offending step rather than a file that silently misbehaves on the cycler. **Arbin** rejects: * `EIS` steps — Arbin schedules have no impedance-sweep step type. * `Subroutine` steps — there's no equivalent step type. * Compound `And` / `Or` end conditions — Arbin step limits accept a single equation per limit. * Maccor user variables (`VAR1`, `VAR2`, …) referenced in step values or `VariableEnd` expressions — Arbin has no runtime variable namespace for them. **Maccor** rejects: * Zero-duration `Rest` steps — Maccor requires every step to have at least one nonzero termination. (This is most commonly hit when round-tripping an Arbin "Internal Resistance" step through UCP.) If you hit one of these, restructure the protocol to avoid the feature, or export to a different target format that supports it. #### Cross-cycler variable translation When a UCP protocol was originally parsed from one vendor and is exported to another, the converter rewrites variable names and cycle-index comparisons so the semantics survive translation: * `PV_CHAN_Cycle_Index`, `PV_CHAN_Voltage`, `PV_CHAN_Current`, `PV_CHAN_Charge_Capacity` / `PV_CHAN_Discharge_Capacity`, `PV_CHAN_Step_Time` (Arbin) ↔ `CYCLE`, `VOLTAGE`, `CURRENT`, `CAPACITY`, `STIME` (Maccor). Both Arbin capacity variants map to Maccor's single `CAPACITY` token; the reverse path (Maccor → Arbin) emits `PV_CHAN_Capacity`. * Arbin's `PV_CHAN_Cycle_Index` is 1-indexed while Maccor's `CYCLE` is 0-indexed. Numeric cycle-index comparisons are shifted by 1 in either direction so the same set of cycles is matched. For example, an Arbin end `PV_CHAN_Cycle_Index <= 2` becomes a Maccor User Def end `CYCLE <= 1`. * UCP-canonical short names (`Capacity`, `Voltage`, `Current`, `Duration`, `Energy`) used in `set_variable` expressions are expanded back to Arbin's `PV_CHAN_*` tokens on Arbin export so the round-trip parser resolves them correctly. Compound `And` / `Or` ends and `VariableEnd` expressions exported to Maccor are emitted as a single `User Def` end with `&` / `|` operators. ## Differences between Commercial Protocols Much of the challenge in converting between the different protocols is not the syntax, but the fundamental differences in how the cyclers define the logic to follow the same sequence of steps. In this section, we'll cover some implementation differences between the Ionworks Universal Cycler Protocol, and the protocols from the cyclers we support. ### CCCV steps Most cyclers define CCCV steps as one step with voltage as a "limit" field: * Maccor: Uses a constant current step, with voltage as a "limit". Maccor also supports a native single-step `Chg Func CCCV` / `Dis Func CCCV` step type that combines the CC and CV phases (with the CV cutoff specified as a current limit). * Neware: Specifies both current and voltage as a "limit" * Novonix: Specifies both current and voltage as fields, and uses step type to differentiate between CC (voltage is a cutoff) and CCCV (transition to voltage and hold until a current cutoff) In UCP, for maximum modularity, we use a [step block](/simulate/universal-cycler-protocol#step-blocks) with two steps, CC with a voltage cut-off and CV with a current cut-off, with total duration and any other end conditions defined at the step block level. When parsing a Maccor `Chg Func CCCV` / `Dis Func CCCV` step, the parser expands it into the equivalent two-step UCP block automatically. ### Header metadata Some formats (e.g., Novonix `.pro2`) include top-level header data (`Version`, `LastUpdated`, `Charger`). This metadata is preserved in UCP under `header` and is used for round-trips when converting back to the original format. ### Functional expressions Maccor supports functional expressions (e.g., `VAR1*0.5`) for step values and end conditions. These are carried through UCP as strings and round-tripped using vendor-specific constructs (e.g., Maccor "User Def" end entries), so the meaning is preserved even if another format lacks an exact equivalent. ### Nested loops For formats that use Do/Loop constructs (Maccor), nested loops are numbered (Do 1/Loop 1, Do 2/Loop 2, …) to reflect structure. UCP expresses loops with `repeat` on a block; when converting back to Maccor, Do/Loop numbering is generated from the block nesting depth. ### Report/Record/Save data/Resolution This is the field that defines how often the cycler will save data to the output time series. * Maccor uses the "Report" field and allows time, current, voltage, and temperature * Neware uses the "Record" field and allows time, current, and voltage * Novonix uses `StepConditions` entries with `ConditionType: "Save data"` and allows `Δt`, `ΔV`, and `ΔI`. * UCP uses the "Resolution" field, which can be set globally and overridden for each step, and currently only supports time ### Loops For loops, there are two fundamental approaches: 1. **Nested Steps:** Define the loop as a step block with a nested step, with a `repeat` parameter specifying the number of times to repeat the loop. This is the more modern approach, similar to how loops are defined in modern programming languages such as Python. The following protocols use this approach: * UCP (using [step blocks](/simulate/universal-cycler-protocol#step-blocks)) * Novonix (using `ChildProtocolStepList` with `TimesToLoop`). ### Increment cycle number * Novonix uses `StepType = 6` to increment the cycle counter. In UCP this maps to an auxiliary `Increment cycle number` step. * Arbin uses the built-in `PV_CHAN_Cycle_Index` variable to track the current cycle. When this variable is incremented via a `Set Variable(s)` step, it maps to both a `set_variable` action and an `Increment cycle number` auxiliary step in UCP. 2. **Goto/State machine:** Define special steps like "start loop" and "end loop". This is the more legacy approach, similar to older programming languages such as Fortran. The following protocols use this approach: * Maccor (using a `Do` step to start the loop, and a `Loop` step to end the loop) * Neware (using a special step type to loop back to a specified previous step a certain number of times) * Arbin (using limit conditions with goto targets to jump between steps, and `Set Variable(s)` steps to manage counters) ### Cycle-index branching (Arbin) Arbin protocols commonly use `PV_CHAN_Cycle_Index` to implement cycle-dependent branching, where different parameters are applied on different cycles. When parsed into UCP, this pattern is represented using [control steps](/simulate/universal-cycler-protocol#control-steps) with `set_variable` actions and `goto` targets. ### Time-varying advanced formulas (Arbin) Arbin schedules can drive a step with an **advanced formula** (an `AF_*` expression) whose value changes over the course of the step — for example a `SIGN(...)`-based piecewise current waveform that alternates polarity at fixed elapsed times. In Arbin, these formulas read the elapsed step time from the built-in process variable `PV_CHAN_Step_Time`. When such a formula is used as a step's control value, the parser translates `PV_CHAN_Step_Time` to the UCP step-time variable [`t`](/simulate/universal-cycler-protocol#example-time-varying-control) and inlines the formula directly into the step value, so the simulator evaluates it as a function of time. The `SIGN(...)` Arbin function maps to UCP's [`sign()`](/simulate/universal-cycler-protocol#dynamic-behavior-with-variables) helper. For example, an Arbin advanced formula equivalent to "+1 A for the first 30 s of the step, then –1 A for the next 30 s" becomes the following UCP step: ```yaml theme={null} - SMC Sign Current Waveform: - Charge: mode: Current value: sign(30 - t) * 1.0 duration: 60 ``` A few details to be aware of: * The waveform carries its own polarity in the expression, so the parser leaves the sign untouched and reports the step as a nominal `Charge` direction regardless of how many times the value flips during the step. * Voltage cut-offs on both sides of the operating window can be attached as `ends`, since the value may push the cell in either direction within a single step. * `PV_CHAN_Step_Time` appearing inside a **termination** equation still means a Duration end and is handled separately — only its use inside the control-value formula maps to `t`. * Advanced formulas that reference only Arbin `MV_UD*` user variables (and don't depend on step time) continue to be seeded as scalar UCP variables at protocol start, exactly as before. ### Last-step read-outs (Arbin) Arbin schedules can log the value a channel had **at the end of the previous step** by reading an `LS_CHAN_*` ("last step") variable inside a `Set Variable(s)` step — for example, capturing the discharge capacity of the discharge that just finished into a user variable for later use: ```ini theme={null} [Schedule_Step1] m_szStepCtrlType=SetValue(s) m_szCtrlValue=MV_UD1 m_szExtCtrlValue1=LS_CHAN_Discharge_Capacity m_szLabel=Log Capacity ``` When parsed into UCP, the `Set Variable(s)` step becomes a [control step](/simulate/universal-cycler-protocol#control-steps) whose `set_variable` action is evaluated against the previous step's data — so `LS_CHAN_*` resolves to that step's final value: ```yaml theme={null} - Log Capacity: - Control: set_variable: - name: MV_UD1 eval: LastStepDischargeCapacity ``` The supported read-outs are: * `LS_CHAN_Discharge_Capacity` → `LastStepDischargeCapacity` * `LS_CHAN_Charge_Capacity` → `LastStepChargeCapacity` Charge and discharge stay distinct — the mapped context variables carry the previous step's directional capacity, not a net signed value. A few details to be aware of: * `LS_CHAN_*` is only valid **directly** in a `Set Variable(s)` source expression, including inside arithmetic (`LS_CHAN_Discharge_Capacity * 0.25`). Using it in a control value, end-condition equation, or a named formula body — even one that a `Set Variable(s)` step later consumes — is rejected at parse time, because in those places it would bind to the wrong step's data. * Energy variants (`LS_CHAN_Discharge_Energy`, `LS_CHAN_Charge_Energy`) are recognised but currently rejected: the simulated per-step data exposes capacity but not energy. If you hit this, use a capacity read-out or file a request. ### Power Simulation drive cycles (Arbin) Arbin schedules can drive a step with a **Power Simulation** waveform — a `.txt` profile referenced by the step whose second column is interpreted as power (watts) rather than current. This is the power-mode counterpart of the existing **Current Simulation** step type; both are parsed into a UCP [`Drive`](/simulate/universal-cycler-protocol#drive-cycles) step, with the step's `mode` set to match: * `Current Simulation` → `Drive` with `mode: Current` (values are amps) * `Power Simulation` → `Drive` with `mode: Power` (values are watts) The referenced `.txt` waveform is uploaded alongside the schedule via the **Additional Files** prompt, exactly like a Current Simulation profile. Voltage step limits carry across unchanged and become `ends` on the `Drive` step. ```yaml theme={null} - Load Cycle: - Drive: mode: Power value: highway_power_profile.txt ends: - Voltage < input['Lower voltage cut-off [V]'] ``` ### Cumulative capacity and energy counters (Arbin) Arbin's `PV_CHAN_Discharge_Capacity`, `PV_CHAN_Charge_Capacity`, `PV_CHAN_Discharge_Energy`, and `PV_CHAN_Charge_Energy` channels accumulate charge and energy throughput across steps until an explicit `Set Variable(s)` reset zeroes them. When an Arbin protocol is parsed into UCP, these four counters are kept as distinct runtime variables (rather than collapsed into a net `Capacity` / `Energy`) so that expressions comparing them evaluate correctly. The counters are: * **Seeded to 0** at the top of the protocol. * **Incremented after every step** by that step's discharge or charge throughput. * **Reset to 0** whenever a `Set Variable(s)` step includes the counter in its reset mask. This lets protocols branch on **net** throughput. For example, a schedule that alternates charge and discharge until the net capacity swings past a target can be written using the difference of the two counters in a `set_variable` expression or a `VariableEnd` condition: ```yaml theme={null} - Log Net Capacity: - Control: set_variable: - name: VAR_NET_CAPACITY eval: PV_CHAN_Discharge_Capacity - PV_CHAN_Charge_Capacity ``` When a **capacity counter** appears on the left side of a step limit (e.g. `Charge to PV_CHAN_Charge_Capacity >= MV_UD1`), the parser rewrites it to a per-step `Capacity` end — protocols typically reset the counter just before such a step, so the counter's value within the step equals that step's own throughput. Directional energy counters can be used in variable expressions, but are not currently supported on the left side of step limits. Round-tripping is preserved: exporting a UCP protocol back to Arbin recognises `PV_CHAN_(Charge|Discharge)_(Capacity|Energy) = 0` set-variable actions and encodes them as reset-mask entries on a `Set Variable(s)` step. ### Drive cycles (BioLogic) BioLogic `.mps` protocols use **User Profile** steps to apply drive cycles (for example, a driving current profile). The waveform is embedded directly in the `.mps` file as an **Urban Profile Table** appended after the technique block, so no additional upload is required. When parsed into UCP, a User Profile step maps to a [`Drive`](/simulate/universal-cycler-protocol#drive-cycles) step: the table's time column defines the step duration, and the value column drives the cell in the corresponding control mode (e.g., `Current`). A `.mps` file with multiple techniques can contain several User Profile steps that share an internal `Ns` index. Each drive cycle is named `UP_tech_step_` (where `` is the technique number) so cycles from different techniques don't collide on import. #### Overriding an embedded drive cycle EC-Lab embeds an **event-compressed** copy of each drive-cycle waveform inside the `.mps` file. The compressed waveform simulates as-is, but if you have the original full-resolution `.txt` profile you can attach it to swap in higher fidelity before simulating. After uploading a `.mps` that contains drive cycles, the **Additional Files** panel shows a **Drive cycle waveforms (optional)** section listing each embedded cycle by name. Drop a file onto any slot to override that cycle for the simulation; leave a slot empty to keep the embedded waveform. Overrides are matched by drive-cycle name, not file name, so you can attach any file you like to each slot. ### Battery Capacity Determination (BioLogic) EC-Lab's **Battery Capacity Determination (BCD)** technique runs a fixed capacity-check sequence — a CC leg toward the first voltage bound (`EM1` by default, since `I1 sign` defaults to charge), an optional CV hold, a single rest (`tR`), then a CC leg toward the opposite bound (`EM2`, since `I2 sign` defaults to discharge) — and is imported as a UCP step block with the corresponding CC, CV, and Rest steps. The direction of each CC leg follows its `I1 sign` / `I2 sign` field, so a discharge-first sequence is equally supported. Both halves of the technique (`Is1`/`N1` and `Is2`/`N2`) support `Set I/C` modes `I` (constant current with `A`, `mA`, `µA` units), `C / N` (C-rate computed from a user-supplied `N` divisor), and `C x N` (C-rate multiplied by `N`), and the `tR` rest duration is parsed from EC-Lab's `h:m:s` string (a duration of `0:00:0.0000` means the rest is disabled and is dropped from the block). ### EIS (BioLogic) Both potentiostatic (**PEIS**) and galvanostatic (**GEIS**) impedance techniques are recognised and converted to a UCP [`EIS`](/simulate/universal-cycler-protocol#eis-steps) step using the frequency bounds from the technique's `ctrl2_val` / `ctrl3_val` fields. The same UCP `EIS` step represents both — the cycler simulator runs an impedance sweep against the configured cell model and returns a Nyquist trace in the results. ### 3-electrode vs 2-electrode cells (BioLogic) How `Ewe` and `Ece` step limits are interpreted depends on the **Potential control** mode set in the `.mps` file header: * `Ewe-Ece` (3-electrode): `Ewe` is the working-electrode (cathode) potential and maps to a `CathodePotential` end condition; `Ece` is the counter-electrode (anode) potential and maps to `AnodePotential`. * Any other mode — `Ewe`, `Ecell`, or no entry (2-electrode full cell): `Ewe` is the terminal voltage and maps to a `Voltage` end condition. `Ece` has no terminal-voltage equivalent and is dropped, because mapping a counter-electrode safety guard to terminal voltage would create a spurious cutoff that often fires at the very start of the step. If a 2-electrode `.mps` parses with no voltage cutoffs where you expected them, check that the header's potential control is set explicitly — leaving it blank is treated as 2-electrode. ### Loop counts (BioLogic) EC-Lab's `Loop` technique stores the number of additional iterations in `nt` (i.e. `nt = 0` runs the loop body once, `nt = 3` runs it four times). UCP's [`repeat`](/simulate/universal-cycler-protocol#step-blocks) is the **total** number of executions, so the parser converts BioLogic `nt` to UCP `repeat = nt + 1`. Loops imported before this change ran one iteration short — re-import the `.mps` to pick up the corrected count. ## PyBaMM experiment strings You can upload a plain text file containing [PyBaMM experiment strings](https://docs.pybamm.org/en/latest/source/api/experiment/index.html) directly into the Protocol Simulator. The system auto-detects the format and converts the steps into UCP, with list repetition blocks mapping to [step blocks](/simulate/universal-cycler-protocol#step-blocks) with a `repeat` count. ### Basic syntax For plain steps, each line in the file is a single PyBaMM step string. The supported step types are: * `Charge at ` — constant current, C-rate, or power charge * `Discharge at ` — constant current, C-rate, or power discharge * `Hold at V` — constant voltage hold * `Rest for ` — open-circuit rest period Steps can include termination conditions with `until` and duration constraints with `for`: ```text theme={null} Charge at 1C until 4.2V Discharge at 0.5C for 1 hour Hold at 4.2V until C/50 Rest for 10 minutes ``` ### List repetition To repeat a sequence of steps multiple times, wrap them in square brackets and multiply with `* N`. This is equivalent to Python's list repetition syntax. ```text theme={null} ["Charge at 1C until 4.2V", "Discharge at 1C until 2.5V"] * 100 ``` This produces a single repeated block in UCP with `repeat: 100`, rather than duplicating the steps 100 times. You can mix repeated blocks with plain steps: ```text theme={null} Charge at 1C until 4.2V Hold at 4.2V until C/50 ["Discharge at 0.5C until 3.0V", "Rest for 10 minutes"] * 50 Rest for 1 hour ``` #### Nested repetition Repeated blocks can be nested for more complex protocols: ```text theme={null} [["Charge at 1C until 4.2V", "Rest for 5 minutes"] * 2, "Discharge at 1C until 2.5V"] * 30 ``` This creates an outer block that repeats 30 times, where each iteration runs the charge-rest pair twice followed by a single discharge. #### Cycle groups Use parentheses inside a list to mark a group of steps as a **cycle**. This automatically inserts an "Increment cycle number" step at the end of each repetition, so cycle-level metrics are tracked correctly: ```text theme={null} [("Charge at 1C until 4.2V", "Discharge at 1C until 2.5V")] * 100 ``` Tuple cycle groups must always be inside a list. Use `[(...)] * N` — not `(...) * N`. #### Multiline format For readability, you can split a repeated block across multiple lines: ```text theme={null} [ "Charge at 1C until 4.2V", "Hold at 4.2V until C/50", "Discharge at 1C until 2.5V", ] * 50 ``` # Simulations Source: https://docs.ionworks.com/simulate/simulations Run single simulations or sweeps with parameterized models, configure protocols and design parameters, and visualize results A **Simulation** is the result of running a [`Parameterized Model`](/build/parameterized-models) with a specific set of inputs and experimental conditions. Simulations are the core of Ionworks, allowing you to explore the performance of your [`Cell Specification`](/core-concepts/cells) beyond what you have measured experimentally. Simulations are always created and viewed within the context of a [`Study`](/simulate/studies). ## Running a New Simulation You can run a new simulation or a sweep of simulations from within a study. The process is broken down into a simple, step-by-step workflow. 1. **Select Cell Specification:** First, choose the cell you want to simulate. 2. **Select Parameterized Model:** Next, select a parameterized model that has been created for your chosen cell. Parameterized models combine a Model (mathematical framework) with specific parameters. 3. **Define Experiment Protocol:** Provide the protocol for your experiment using one of these methods: * **Start from an existing protocol** — select a saved protocol from the searchable dropdown * **Paste or type** — enter protocol text directly in the editor (supports UCP, Arbin, Maccor, Neware, BioLogic, Novonix, and other cycler formats) * **Upload a file** — upload a protocol file from your cycler software * **Interactive Builder** — use the visual protocol builder to construct a protocol step by step 4. **Configure Simulation Parameters:** Set initial conditions (SOC, temperature) and any protocol input parameters. If the protocol uses `input["..."]` expressions, these are automatically detected and shown as configurable fields. 5. **Configure Design Parameters (Optional):** You can also vary the parameterized model's parameters to explore their effect on performance (e.g., "Positive electrode thickness \[m]"). See [Design Parameters](#design-parameters) below for details. **Running Sweeps:** For any experiment or design parameter, you can enter multiple values separated by commas (e.g., `1, 2, 5`). Ionworks Studio will automatically create and run a simulation for every possible combination of the parameters you've entered. The UI will show you the total number of simulations that will be run. To save you time and computational resources, Ionworks Studio automatically checks if an identical simulation has already been run. Existing results are reused, and new results are saved for future use. ## Design Parameters Design parameters allow you to override specific values in your battery model to explore how changes to the cell's physical and electrochemical properties affect performance. By default, simulations use the parameter values defined in your model. Design parameters let you modify these values without changing the model itself. Custom variables defined on the model are also available in simulation output. See [Custom Variables](/build/custom-variables) for details. The available parameters for design overrides depend on your selected model. Only scalar parameters that are actually used in the model can be chosen as design parameters—parameters defined as functions or interpolating tables are not eligible. ### How to Use Design Parameters When setting up a simulation, you'll see a "Design Parameters" section where you can: 1. **Add Parameters** - Click "Add Design Parameters" to start customizing values 2. **Select Which Parameters to Vary** - Choose from the list of parameters available in your model (geometry, electrode properties, separator characteristics, etc.) 3. **Set Values** - Enter the value(s) you want to use for each parameter ### Parameter Input Methods For each design parameter you add, you can choose how to specify values: #### Discrete Enter specific values separated by commas. This is the most direct method when you know exactly which values you want to test. **Example:** * Enter `50e-6, 75e-6, 100e-6` for `Negative electrode thickness [m]` * This will test exactly these three thicknesses #### Range Specify a minimum value, maximum value, and how many evenly-spaced points to generate between them. Useful for exploring a continuous range. **Example:** * Min: `50e-6`, Max: `100e-6`, Count: `5` * This automatically generates: `50e-6, 62.5e-6, 75e-6, 87.5e-6, 100e-6` #### Normal (Statistical) Specify a mean (center), standard deviation (spread), and number of points. Points are distributed around the mean following a normal distribution pattern (±2 standard deviations). **Example:** * Mean: `75e-6`, Std: `10e-6`, Count: `5` * This generates values clustered around 75 microns with more samples near the center ### Combining Parameters When you add multiple design parameters, simulations will run for combinations of all values. For example: * `Negative electrode thickness`: `50e-6, 75e-6, 100e-6` (3 values) * `Negative particle radius`: `3e-6, 5e-6` (2 values) * Total simulations: **6** (3 × 2) ### Advanced: Sampling Methods For large parameter sweeps, you can choose different sampling methods instead of testing every combination: * **Grid** (default) - Tests all combinations of the values you provide * **Latin Hypercube Sampling (LHS)** - Efficiently samples the parameter space with fewer simulations * **Sobol** - Low-discrepancy sequence for uniform coverage * **Random** - Random sampling within the specified ranges These methods are particularly useful when sweeping many parameters to avoid an explosion in the number of simulations. ### What Happens Without Design Parameters If you don't add any design parameters, the simulation will use the default values from your selected model. This is perfectly fine for most use cases - design parameters are optional and only needed when you want to explore variations from your baseline model. ## Visualizing and Comparing Results Once your simulations are complete, they are added to the study's results page, which provides powerful tools for analysis. Simulation results are organized by "experiment type" so that you can compare like-for-like results within a single experiment type - for example, investigating the effect of C-rate on capacity in a constant current discharge. You can switch between two views: ### Data View This is a table containing the results of all simulations within the study. It includes the inputs for each simulation and the key summary metrics that were calculated. You can sort any column by clicking its header — click once for ascending order, click again for descending, and a third time to clear the sort. Use sorting and filtering together to find specific runs quickly. When you run a simulation using a built-in [experiment template](/simulate/experiment-templates), metrics like capacity, energy, resistance, and electrode potentials are automatically computed and included in the results. See the [experiment templates reference](/simulate/experiment-templates#template-metrics) for the full list of metrics each template provides. Design parameter columns are visible by default so you can quickly compare how different parameter values affect results. Experiment parameter and metric columns are hidden by default but can be toggled on from the column visibility menu. ### Visualization View This is a customizable dashboard where you can create plots to visualize and compare your simulation results. * **Add Plots:** Click the "Add Plot" button to configure and add a new visualization to your dashboard. You can choose between "time series" (whole variable trace vs time or capacity) and "metrics" (single values e.g. "Capacity" vs "C-rate"). * **Custom Layout:** You can add multiple plots to your dashboard to compare different simulations or look at different variables. The plots can be dragged, dropped, and resized to create the perfect layout for your analysis. * **Interactivity:** The plots are fully interactive, allowing you to zoom, pan, and hover over data points to get more detail. Any new simulations you run within the same study will automatically be added to the plots. This flexible visualization tool allows you to build a comprehensive view of your simulation results, tailored to your specific analysis needs. ## Canceling simulations You can cancel a running simulation if it is still in the **Pending** or **Processing** state. 1. Open the simulation from the Data View 2. Click the **Cancel** button in the top-right area of the detail page 3. Confirm the cancellation in the dialog Once confirmed, the simulation and its underlying job are moved to a **Canceled** status. Any child jobs associated with the simulation are also canceled. Canceling a simulation cannot be undone. Any work already completed is lost. You can rerun the simulation to get results. You can also cancel multiple simulations at once from the simulation list. Select the simulations you want to cancel, then use the **Cancel** bulk action. Simulations that have already finished are automatically skipped. ## Rerunning simulations You can rerun one or more simulations directly from the Data View. Select the simulations you want to rerun, then use the rerun action. When you rerun a simulation, all of its original configuration is preserved — including experiment parameters, design parameters, and the parameterized model — so the new run reproduces the same conditions as the original. This is useful when: * A simulation failed due to a transient issue and you want to retry it * You want to force a fresh run to bypass cached results * A simulation was canceled and you want to run it again The rerun action uses force rerun by default, which means a new simulation job is always submitted even if matching cached results exist. ## Troubleshooting ### Why did my simulation produce no data? A simulation can complete successfully but record no time series — the plot is empty and the metrics are not meaningful. When this happens, the results page shows a warning that explains which of the cases below was hit. The reason is derived from the steps table and the `Stop reason` / `Early termination reason` metrics on the run. #### All steps skipped — no data generated > **All steps skipped — no data generated** > Every step ended immediately because its termination condition was already satisfied before the simulation began. This happens when the battery's initial state already meets the termination condition for every step in the protocol. For example, trying to discharge a battery that is already fully depleted, or charging one that is already at 100% SOC. **Common causes and fixes:** | Cause | Fix | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Discharging a battery that is already depleted | Increase the **Initial SOC** value in the simulation parameters | | Charging a battery that is already fully charged | Decrease the **Initial SOC** value | | C-rate or current too high for the cell — the instantaneous voltage drop at t=0 immediately trips the voltage cutoff, so the termination condition is already met before the step starts | Lower the C-rate or current to a realistic value for the cell's capacity (e.g. a 50 A discharge is unrealistic for a 2 mAh coin cell) | | Voltage cutoff already reached at initial state | Adjust the cutoff voltage or initial conditions so the simulation has room to run | #### Protocol ended early — no data generated > **Protocol ended early — no data generated** > A termination condition was met before any time series data was recorded. The protocol hit a global termination condition (for example, *Total time ≥ 0 h*) before the first simulation step had a chance to record any points. Review the termination conditions configured on the protocol and relax the one that fired — the warning includes its description. #### Reached a pause or end step before any data was recorded > **No data generated** > The protocol reached a pause step before any step ran, so no time series data was recorded. Enable "Treat pause steps as 0-duration rest" to run through pauses. The protocol's control flow jumped to a `Pause` or `End` step before any charge, discharge, or rest step executed. The simulation finishes with `Stop reason` set to either `Reached pause step` or `Reached end step` so you can tell which auxiliary step stopped it. To fix this: * **Pause step:** enable **Treat pause steps as 0-duration rest** so pauses don't halt the protocol. With this option on, pauses act like an instantaneous rest and the protocol continues to the next step. * **End step:** review the control flow conditions that routed to the end step on the first iteration. If you're unsure what went wrong, check the **experiment protocol** panel on the results page to review the parsed steps and their termination conditions. ### Protocol configuration errors Some failures are caused by a misconfigured protocol rather than a solver problem. These surface with a specific, actionable message instead of a generic "An unexpected error occurred": | Message | Cause | Fix | | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **Maximum number of backward jumps (…) exceeded. Protocol may contain an infinite loop.** | A `goto` keeps jumping backward to an earlier step block, exceeding the allowed jump budget. | Add an exit condition (e.g. cycle count, capacity threshold) to the loop, or restructure the control flow so it eventually moves forward. | ## Next steps * Learn about [Protocols](/simulate/protocols) including built-in experiment templates * Explore [Optimization](/optimize/overview) to find optimal parameter values automatically # Studies Source: https://docs.ionworks.com/simulate/studies Group related simulations into focused investigations to compare results and validate models against experimental data # Studies Studies are focused investigations within a project where you run simulations, compare results, and analyze battery performance. They provide a structured way to organize your experimental work. A study is a container: every [simulation](/simulate/simulations) (and any validation report) you create lives inside a study. ## What is a Study? A study represents a specific investigation or analysis within a project. For example: * **Charge Protocol Optimization** - Testing different charging rates and strategies * **Temperature Performance Analysis** - Evaluating cell behavior across temperature ranges * **Cycle Life Comparison** - Comparing degradation under different usage patterns * **Model Validation** - Testing model predictions against experimental data and generating validation reports ## Study Workflow ### 1. Create a Study Studies live inside projects. Navigate to your project and create a new study with a descriptive name that captures what you're investigating. ### 2. Run Simulations Within a study, you can: * Select a cell and model * Choose a [protocol or experiment template](/simulate/protocols) * Configure parameters (temperature, C-rate, voltages, etc.) * Run the simulation ### 3. Analyze Results Once simulations complete, you can: * View time-series data (voltage, current, temperature, etc.) * Compare multiple simulations side-by-side * Export data for further analysis * Visualize trends and patterns ### 4. Validate against experimental data If you have experimental measurement data uploaded for your cell, you can generate a validation report to compare simulation predictions against real-world results. See [Validation report](#validation-report) below. ## Validation report The validation report lets you compare simulation results against experimental measurement data directly within a study. This is useful when you want to verify that your model accurately reproduces real-world battery behavior. ### When to use validation Use a validation report when you want to: * Verify that a parameterized model accurately predicts experimental results * Quantify the agreement between simulation and measurement using error metrics * Visually compare simulated and measured voltage, current, or other variables * Evaluate model performance across different operating conditions (e.g., C-rates, temperatures) ### Setting up validation Run one or more simulations using the cell, model, and protocol that match your experimental conditions. For example, if your experimental data is a 1C constant current discharge at 25°C, run a simulation with the same protocol and conditions. Open the validation setup from your study. Select which simulation to compare against which experimental measurement. Each simulation can be paired with a corresponding measurement from your uploaded [cell data](/data/overview). For each validation row, select how the simulation should be initialized. The default **Auto-detect** option works well when the measurement starts from a known state; see [Initial conditions](#initial-conditions) below for the full set of options. Once your simulation-to-measurement mappings and initial conditions are configured, generate the validation report. The report appears in a dedicated **Report** tab within the study. Your experimental data must be uploaded to Ionworks Studio before you can use it for validation. See the [Data Overview](/data/overview) for how to upload and manage measurement data. ### Initial conditions When you set up a validation, you can choose how the simulation is initialized for each simulation-to-measurement pair. This controls the starting state of the cell model so that it matches the conditions under which the experimental data was collected. | Option | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Auto-detect** | The initial condition is inferred from the first data point of the measurement. This is the default and works well when the measurement starts from a known state. | | **Custom voltage** | Specify a starting voltage manually. Use this when the auto-detected value doesn't match the true starting condition, or when you want to override it for consistency. | | **Custom state of charge** | Specify a starting state of charge (SOC) as a value between 0 and 1. Use this when you know the SOC at the start of the experiment but the open-circuit voltage relationship makes voltage-based initialization unreliable. | If two measurements share the same initial voltage but start from different states of charge (for example, due to a non-unique OCV–SOC relationship), use **Custom state of charge** and specify a different SOC value for each row to ensure each simulation is initialized correctly. ### Managing validation rows Each simulation-to-measurement pair is displayed as a row in the validation setup. You can remove individual rows by clicking the delete button on a row, which prompts a confirmation dialog before removing it. ### Drive cycle validation Validation reports support [drive cycle](/simulate/universal-cycler-protocol#drive-cycles) protocols in addition to standard experiment templates. This means you can validate your model against real-world driving profiles or other complex time-varying protocols. When you map a simulation that uses a drive cycle protocol to a measurement, the validation report compares the simulated response against the measured data across the full drive cycle, including variable current and power profiles, using the same overlay plots and error metrics. ### Reading the report The validation report provides: * **Overlay plots** comparing simulated and measured data for each mapped pair, so you can visually inspect how well the model tracks the experiment. Grid lines are included for easier reading. * **Error metrics** that quantify the agreement between simulation and measurement, such as root mean squared error (RMSE) and mean absolute error (MAE) * **Per-simulation breakdowns** so you can identify which operating conditions the model handles well and where it diverges ## Key Features ### Smart Simulation Reuse Before running a new simulation, the system checks if an identical one has already been completed. If found, it reuses the existing results instantly - saving time and computational resources. ### Non-Destructive Associations Studies only provide "views" into simulation results. This means: * A simulation can be associated with multiple studies * Removing a simulation from a study doesn't delete the results * You can reorganize your work without losing data ### Collaborative Research All studies within a project are accessible to organization members. This enables: * Team collaboration on investigations * Sharing of results across teams * Consistent methodology across experiments ## Organization Hierarchy ``` Organization └── Project └── Study ├── Simulations └── Validation Report (simulation vs. measurement) ``` ## Best Practices ### Name Studies Descriptively Use clear, descriptive names that indicate what you're investigating: * ✅ "Fast Charging Safety Analysis - 25°C" * ✅ "Model Validation vs Arbin Data" * ❌ "Study 1" * ❌ "Test" ### Group Related Simulations Keep related simulations together in the same study. For example, if you're testing different C-rates at 25°C, run all those variations in one study for easy comparison. ### Use Multiple Studies for Different Conditions Create separate studies when investigating substantially different conditions: * One study for 25°C performance * Another study for 45°C performance * A third study for low-temperature behavior This makes it easier to organize and find results later. ## Next Steps * Learn about [Simulations](/simulate/simulations) * Explore [Protocols](/simulate/protocols) and built-in templates * Understand [Projects and Studies](/core-concepts/projects-studies) hierarchy * Upload experimental data for use in validation — see [Uploading data](/data/uploading) # Universal Cycler Protocol Source: https://docs.ionworks.com/simulate/universal-cycler-protocol Full reference for UCP, Ionworks' YAML-based language for defining battery cycling experiments and protocol steps The Universal Cycler Protocol (UCP) is a flexible YAML-based language for defining battery cycling experiments. It allows you to create complex experimental sequences with programmatic control. You can create and edit UCP protocols visually using the [Protocol Builder](/simulate/protocol-builder), or by converting a [commercial protocol](/simulate/simulating-commercial-protocols) into UCP format. This page is the full reference for the underlying UCP file format. ## Global Configuration You can define global settings for the entire protocol under the optional `global` key. ```yaml theme={null} global: initial_temperature: 25 initial_state_type: soc_percentage # or "voltage" initial_state_value: 100 # percent if soc_percentage, volts if voltage resolution: time: 60 ``` * `initial_temperature`: The starting temperature for the experiment in degrees Celsius. Defaults to `25`. When used in a [design optimization](/optimize/running-optimization#define-the-experiment), this value is automatically converted to Kelvin and applied as both the initial and ambient temperature for the simulation. * `initial_state_type`: How to define the initial state of the cell. Allowed values: * `soc_percentage`: Interpret `initial_state_value` as a percentage of state of charge (0–100). * `voltage`: Interpret `initial_state_value` as a starting voltage in Volts. * `initial_state_value`: The initial value for the selected type. * `resolution`: The time resolution for the simulation output in seconds. Defaults to `60`. ## Safety Limits You can define optional safety limits for the simulation. If any of these are breached, the simulation jumps to a recovery step (`goto`) or, if no `goto` is supplied, ends the test. Each limit may be given as a bare number or as an object with an explicit `goto` so that different fault conditions can route to different recovery steps: ```yaml theme={null} safety_limits: # Object form — this limit has its own dedicated recovery step. voltage_max: value: 4.525 goto: Pause_Voltage_Fault delay: 3 # Optional debounce in seconds. The safety only trips when # ``Voltage > 4.525 V`` AND the step has been running for more # than 3 s, mirroring the Maccor ``VOLT>=4.525&STIME>3`` idiom. # Bare-number form — falls back to the protocol-level `goto` below. voltage_min: 2.5 temperature_min: -20 temperature_max: value: 60 goto: Pause_Thermal_Fault charge_current_max: 10 # Max charge current in Amps (positive value) discharge_current_max: 15 # Max discharge current in Amps (positive value) # Optional fallback target for any limit that doesn't specify its own `goto`. goto: Pause_Generic_Fault ``` Resolution order when a limit triggers: 1. If the triggering limit has its own `goto`, jump there. 2. Otherwise, if `safety_limits.goto` is set, jump there. 3. Otherwise, the test ends. If a step's termination condition (see below) has the same value as a global `safety_limit`, the safety limit takes precedence. ## Simulation Steps Each simulation step is defined as a dictionary with a single key that defines the step's **direction**. The value of this key is a dictionary containing the step's parameters. The primary step directions are: * `Rest`: The cell is at rest. * `Charge`: The cell is being charged. * `Discharge`: The cell is being discharged. * `Drive`: A drive cycle step (see below). * `EIS`: An Electrochemical Impedance Spectroscopy step (see below). ### Step Parameters * `mode`: The control mode for the step (e.g., `C-rate`, `Current`, `Power`, `Voltage`). Required unless the step is `Rest`. * `value`: The setpoint for the control mode. This can be a fixed number or a dynamic expression. * `duration`: The maximum duration of the step in seconds. * `ends`: A list of one or more cut-off conditions. A step must have either a `duration` or at least one `ends` condition. * `temperature`: (Optional) The ambient temperature for this specific step in Celsius, overriding the global setting. * `resolution`: (Optional) The time resolution for this specific step, overriding the global setting. * `set_variable`: (Optional) A list of variables to calculate and set *after* the step completes. * `note`: (Optional) A string for informational purposes. ### Parameterized Steps with `input['...']` You can use `input['...']` syntax in `value`, `duration`, and `ends` fields to create parameterized protocols. When running the protocol, the actual values are provided at runtime. ```yaml theme={null} steps: # Parameterized step value - Discharge: mode: C-rate value: input["C-rate"] ends: - Voltage < input["Cut-off voltage [V]"] # Parameterized duration - Rest: duration: input["Rest duration [s]"] # Parameterized duration in ends - Discharge: mode: Power value: 2 ends: - "Duration > input['Cruise duration [s]']" - Voltage < 3.0 # Expressions with inputs - Rest: duration: input["Duration [s]"] / 2 ``` Inputs can also be used in `resolution`, `global` settings, and `set_variable` expressions. See [Experiment Templates](/simulate/experiment-templates) for complete examples. ### Termination Conditions (`ends`) The `ends` conditions define when a step should terminate. ```yaml theme={null} # Simple termination ends: - "Voltage > 4.2" # Derivative-based termination ends: - "d/dt(Voltage) < 0.001" # Termination with a `goto` to another step block ends: - "Voltage < 2.7": goto: My_Next_Step # Termination that ends the entire test ends: - "Voltage < 2.5": goto: End Test ``` * **Supported Types:** `Voltage`, `Current`, `C-rate`, `Capacity`, `Temperature` (case-insensitive). * **Supported Operators:** `<` and `>`. * **Sign Convention**: For `Current`, `C-rate`, and `Capacity` terminations, always provide a positive value. The engine automatically handles the internal sign convention (e.g., negative current for charge). For `Voltage` and `Temperature`, signs are respected as written. * **Derivative Terminations**: To terminate based on the rate of change of a variable, use the format `"d/dt(Type) operator value"`. If a step's termination condition is met at the very beginning (e.g., trying to charge a battery that is already at 4.2V), the step will be skipped. Any `goto` transitions on that termination condition will **not** be executed. The step's `set_variable` entries **are** still evaluated, so accumulators and reference values you compute at the end of a step remain consistent whether the step ran or was skipped. ### Example: Derivative Termination Derivative-based terminations are useful for ending a step based on the stability of a signal. For example, a constant-voltage charge phase can be terminated when the current stops changing, which indicates the battery is full. ```yaml theme={null} - CV Charge with Current Stability Check: - Charge: mode: Voltage value: 4.2 # Constant Voltage phase ends: - "d/dt(Current) < 0.001" ``` ### Example: CCCV Charge ```yaml theme={null} - CC Charge: - Charge: mode: C-rate value: 1 ends: - "Voltage > 4.2" - CV Charge: - Charge: mode: Voltage value: 4.2 ends: - "Current < 0.05" ``` ### Ambient Temperature Steps The `Ambient Temperature` step changes the ambient (chamber) temperature mid-protocol. It is an instantaneous step — it sets the chamber setpoint and immediately advances to the next step; the simulation continues at the new ambient temperature until another `Ambient Temperature` step is reached. * `temperature_setpoint`: Required. The new ambient temperature in degrees Celsius. Must be greater than absolute zero. * `temperature_ramp_rate`: (Optional) Ramp rate in °C/min. Defaults to `2.0`. ```yaml theme={null} - Soak at 45 C: - Ambient Temperature: temperature_setpoint: 45 - Rest: duration: 3600 - Cool to 25 C: - Ambient Temperature: temperature_setpoint: 25 - Rest: duration: 1800 ``` Use this step for thermal-soak segments, RPT diagnostics that run at a fixed reference temperature, or any protocol whose chamber setpoint changes during the test. An `Ambient Temperature` step overrides the `global.initial_temperature` setting (and any prior step's `temperature` override) from that point in the protocol onward. ### EIS Steps The `EIS` step performs an Electrochemical Impedance Spectroscopy measurement at the current state of the cell. * `lower_frequency`: The lower frequency bound for the EIS sweep in Hz. * `upper_frequency`: The upper frequency bound for the EIS sweep in Hz. Both frequency parameters can be a fixed number or a dynamic expression. EIS steps are not supported in [design optimization](/optimize/running-optimization#define-the-experiment) experiments. Use EIS steps in standalone [simulations](/simulate/simulations) instead. EIS steps run against both physics-based models (using `pybamm.EISSimulation` under the hood) and Equivalent Circuit Models. For an ECM the impedance is computed analytically from the series resistance parameter `R0 [Ohm]` and each indexed `R_rc (i) [Ohm]` / `C_rc (i) [F]` parallel branch — so the Protocol Simulator, which uses an ECM, can run EIS steps from uploaded protocols (e.g. BioLogic PEIS/GEIS techniques) without any extra configuration. Analytic ECM EIS requires the resistance and capacitance parameters to be constants; SOC- or current-dependent ECM parameters are rejected with a clear error. In the simulation results view, each EIS step is marked with a vertical line on the time-domain plots at the instant the impedance sweep was taken; hovering the marker shows an **EIS step N** tooltip so you can correlate the Nyquist trace with its position in the cycling history. #### Example: Basic EIS Perform an EIS measurement after a 30-minute rest period. ```yaml theme={null} - Rest: - Rest: duration: 1800 - Impedance Measurement: - EIS: lower_frequency: 0.1 upper_frequency: 1000 ``` #### Example: EIS with Dynamic Frequencies The frequencies can be calculated dynamically using variables. ```yaml theme={null} - Set Up Frequencies: - Control: set_variable: - name: VAR_UPPER_FREQ eval: 10 * input['Frequency Multiplier'] - Perform EIS: - EIS: lower_frequency: VAR_UPPER_FREQ / 1000 upper_frequency: VAR_UPPER_FREQ ``` ## Control Steps Control steps are used for programmatic logic and do not run a simulation. They are ideal for setting variables or creating loops with `goto`. ```yaml theme={null} - Setup and Jump: - Control: set_variable: - name: VAR_C_RATE eval: 1.5 - "Increment cycle number" ``` ### Control step parameters * `set_variable`: (Optional) A list of variables to set (see [dynamic behavior with variables](#dynamic-behavior-with-variables)). * `goto`: (Optional) The name of the step block to jump to after executing this control step. This enables non-sequential control flow, such as branching to different steps based on variable values. ```yaml theme={null} - Assign C-rate and Continue: - Control: set_variable: - name: VAR_C_RATE eval: 0.33 goto: Start_Discharge ``` Special string commands are also available: * `"Increment cycle number"`: Increments the internal cycle counter. * `"End"` or `"Pause"`: Terminates the entire simulation. ### Reserved `goto` targets In addition to jumping to a named step block, `goto` accepts the reserved target `End Test`, which stops the simulation immediately. This works from both step termination conditions (`ends`) and from Control step `goto` fields, and is also valid inside subschedules — jumping to `End Test` from a subschedule ends the entire test, not just the subschedule. A Control step's `goto` runs **unconditionally** every time the step is entered, so use it to terminate a branch you have already routed into: ```yaml theme={null} # Unconditional: entering this Control step always ends the test - Stop Here: - Control: goto: End Test ``` To terminate **conditionally**, attach the `goto` to a step termination condition instead — the jump fires only when that condition is met: ```yaml theme={null} # Conditional: only jumps to End Test when the voltage drops below 2.8 V - Discharge until fault: Discharge: C-rate: 0.33 ends: - "Voltage < 2.8": goto: End Test ``` Use `End Test` when you want a fault condition or logic branch to terminate the run explicitly rather than falling through to the next step. ## Dynamic Behavior with Variables You can define variables using `set_variable` and use them to create dynamic, responsive protocols. * `name`: The name of the variable. Must start with `VAR_`. * `eval`: A Python expression to be evaluated. The result must be a number. The `eval` expression can use: * **User Inputs:** `input['...']` * **Other Variables:** Any `VAR_` variable already defined. * **Special Variables:** `t` (step-relative time in seconds), `Cycle` (0-indexed cycle counter). * **Simulation Results:** `Voltage`, `Current`, etc. from the previous step. * **Helper Functions:** `first()`, `last()`, `mean()`, `abs()`, `sign()`, `min()`, `max()`, `ifelse()`. ### Initialising variables Expressions are evaluated eagerly — `ifelse(condition, A, B)` evaluates **both** branches before picking one — so a variable referenced anywhere in an expression must already exist. Use a `Control` step at the top of the protocol to seed every variable you'll write to: ```yaml theme={null} - Initialize Variables: - Control: set_variable: - { name: VAR_REFERENCE_CAPACITY, eval: "0" } - { name: VAR_PEAK_TEMP, eval: "25" } note: Seed accumulator variables before the cycling loop. ``` This matters most for the common "set on first cycle, carry over otherwise" idiom: ```yaml theme={null} set_variable: - name: VAR_REFERENCE_CAPACITY # Without an init Control step above, VAR_REFERENCE_CAPACITY is undefined # the first time this expression runs and the simulation will fail. eval: ifelse(Cycle == 0, last(Capacity), VAR_REFERENCE_CAPACITY) ``` Protocols imported from a Maccor `.000` file automatically gain a leading `Initialize Variables` `Control` block that seeds every referenced `VARn` to 0 — this mirrors the way real Maccor cyclers default user variables in hardware. You'll see it at the top of the parsed UCP YAML. ### Example: Time-Varying Control Linearly ramp the C-rate from 0.1 to 1.1 over one hour. ```yaml theme={null} - Ramp Discharge: - Discharge: mode: C-rate value: 0.1 + t / 3600 duration: 3600 ``` Time-varying values can also change sign within a step — for example, a piecewise current waveform built with `sign()` that charges for the first 30 s and then discharges for the next 30 s: ```yaml theme={null} - Piecewise Current: - Charge: mode: Current value: sign(30 - t) * 1.0 duration: 60 ends: - Voltage > input["Upper voltage cut-off [V]"] - Voltage < input["Lower voltage cut-off [V]"] ``` When a step value is an explicit function of `t`, the waveform may legitimately drive the cell in both directions during the single step. The usual rule that a `Charge` step can only carry an upper voltage cut-off (and a `Discharge` step a lower one) is relaxed for time-varying values, so you can attach safety cut-offs on both sides. ### Example: Conditional Logic Use the `ifelse` helper to create conditional logic. This example defines a subsequent step's direction based on the final voltage of the previous step. ```yaml theme={null} - Discharge and Check: - Discharge: mode: C-rate value: 1 duration: 600 set_variable: - name: VAR_NEEDS_CHARGE eval: ifelse(last(Voltage) < 3.5, 1, 0) - Conditional Step: - Direction[ifelse(VAR_NEEDS_CHARGE == 1, "Charge", "Rest")]: mode: C-rate value: 1 ends: - "Voltage > input['Upper voltage cut-off [V]']" ``` ## Step Blocks Steps can be grouped into named blocks. This is essential for `goto` targets and for repeating a sequence of steps using the `repeat` keyword. ```yaml theme={null} - Ten Pulses: - Discharge: mode: C-rate value: 5 duration: 1 - Rest: duration: 1 repeat: 10 ``` A block's name cannot be one of the reserved step types (`Charge`, `Discharge`, `Rest`, `Control`, etc.). The reserved `goto` target `End Test` is allowed as a jump destination but cannot be used as a block name. ## Drive Cycles You can use a drive cycle for complex profiles by passing data to the `solve_protocol` function in your Python code and referencing it in the YAML. * **In Python:** Pass a `drive_cycles` dictionary where values are 2-column NumPy arrays (time, value). * **In YAML:** Use the `Drive` direction. The `value` is the name of the drive cycle from the dictionary. The duration is defined by the time column in the data. * **`mode`:** Controls how the second data column is interpreted. Use `Current` (default, amps) for a current profile, or `Power` (watts) for a power profile. Voltage cut-offs can be attached to either mode via `ends`. ```yaml theme={null} # Current-mode drive cycle (values in amps) - US06 Drive Cycle: - Drive: mode: Current value: US06 # Power-mode drive cycle (values in watts) - Power Profile: - Drive: mode: Power value: LoadProfile ends: - Voltage < input['Lower voltage cut-off [V]'] - Voltage > input['Upper voltage cut-off [V]'] ``` ## Subroutines Subroutines are reusable sequences of steps defined in your Python code and called from the YAML protocol. This is useful for standard procedures like a CCCV charge. * **In Python:** Pass a `subroutines` dictionary to `solve_protocol`. The values are lists of steps. * **In YAML:** Use the `Subroutine` step type with the name of the subroutine to execute. ```yaml theme={null} # In your main protocol.yaml file steps: - Initial Rest: - Rest: duration: 1800 - "Increment cycle number" - Subroutine: CCCV # This calls the "CCCV" subroutine defined in Python ``` ## Output The protocol solver returns a pandas DataFrame containing the results of the simulation, including time, voltage, current, temperature, cycle number, and any custom variables defined with `set_variable`. For EIS steps, the output DataFrame includes frequency-domain impedance data instead of time-domain data: | Column | Description | | ---------------- | -------------------------------------------------------------- | | `Frequency [Hz]` | Excitation frequency | | `Z_Re [Ohm]` | Real part of impedance | | `Z_Im [Ohm]` | Imaginary part of impedance (negative for capacitive behavior) | During EIS steps, time-domain columns (`Time [s]`, `Voltage [V]`, `Current [A]`) are present but contain `NaN` values. The `Step count` column identifies which step each row belongs to, allowing you to separate EIS data from cycling data. # Which package do I need? Source: https://docs.ionworks.com/which-package Choose between Ionworks Studio, the ionworks-api Python client, ionworks-schema, and ionworksdata Ionworks is one platform with several front doors. Most people need one of them, and picking the wrong one is the most common way to get stuck early. ## Start here | If you want to… | Use | | ---------------------------------------------------------------------------------- | ----------------------------------------------------- | | Click through cells, measurements, and simulations without writing code | [Ionworks Studio](/introduction) — nothing to install | | Script the platform: upload data, run simulations, submit pipelines, fetch results | [`ionworks-api`](/api-client) | | Describe a pipeline — what to fit, against which data, with which optimizer | [`ionworks-schema`](/build/parameterize/overview) | | Convert raw cycler files into the Ionworks data format on your own machine | [`ionworksdata`](/data/preparing-data) | Most scripted workflows use **`ionworks-api` and `ionworks-schema` together**: `ionworks-schema` describes the pipeline, `ionworks-api` submits it and collects the result. ## The packages in full | Package | Install | Import | Reach for it when | | ----------------- | ----------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Ionworks Studio | — | — | You want the UI. Everything below is also available in the browser. | | `ionworks-api` | `pip install ionworks-api` | `import ionworks` | You are automating the platform from Python — data upload, simulations, pipeline submission, results. | | `ionworks-schema` | `pip install ionworks-schema` | `import ionworks_schema as iws` | You are building a pipeline config: `iws.Pipeline`, `iws.SimplePipeline`, `iws.DataFit`, objectives, costs, optimizers. | | `ionworksdata` | `pip install ionworksdata` | `import ionworksdata` | You have raw cycler exports (Arbin, Maccor, Basytec, Neware, Digatron, …) to convert locally before upload. | **The distribution name is not always the import name.** `pip install ionworks-api` gives you `import ionworks`, and `pip install ionworks-schema` gives you `import ionworks_schema`. There is no package called `ionworks` on its own — `pip install ionworks` will not get you the API client. ## Next steps Get from nothing to a first result. Install, authenticate, and meet the sub-clients. Describe fits and validations with `ionworks-schema`. Convert raw cycler files with `ionworksdata`. # Add a custom variable to an existing model Source: https://docs.ionworks.com/api-reference/add-a-custom-variable-to-an-existing-model https://api.ionworks.com/openapi.json post /models/{model_id}/custom-variables Append a new custom variable to a model's config (append-only). Validates the expression, serializes it to JSON, and stores it. Custom variables cannot be edited or deleted after creation because evaluated values are persisted in simulation result files. # Add a user to a project or update their project role Source: https://docs.ionworks.com/api-reference/add-a-user-to-a-project-or-update-their-project-role https://api.ionworks.com/openapi.json post /projects/{project_id}/members Upsert (insert or update) a user's role for a project. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. The target user must already belong to the caller's organization. # Create a properties-type measurement directly Source: https://docs.ionworks.com/api-reference/cell-instances/create-a-properties-type-measurement-directly https://api.ionworks.com/openapi.json post /cell_instances/{cell_instance_id}/cell_measurements/create Create a properties-type measurement without the upload flow. Use this for manual measurements like thickness, weight, etc. # Delete a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/delete-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json delete /cell_instances/{cell_instance_id} Deletes an existing cell instance identified by its ID, including: - All measurements (cascades to delete measurement steps) - All associated data files in storage bucket for all measurements - The instance itself # Get a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/get-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json get /cell_instances/{cell_instance_id} Retrieves a specific cell instance by its ID. # Initiate a signed URL upload for measurement data Source: https://docs.ionworks.com/api-reference/cell-instances/initiate-a-signed-url-upload-for-measurement-data https://api.ionworks.com/openapi.json post /cell_instances/{cell_instance_id}/cell_measurements/initiate-upload Initiate upload for time_series or file measurement data. Each returned upload target also carries an optional S3 ``multipart`` block, which clients use instead of the signed URL for large files. Its session token is the caller's own JWT, so storage RLS still applies. # List cell measurements for a cell instance Source: https://docs.ionworks.com/api-reference/cell-instances/list-cell-measurements-for-a-cell-instance https://api.ionworks.com/openapi.json get /cell_instances/{cell_instance_id}/cell_measurements List cell measurements for an instance with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, created_by_email): Use ``ilike.%value%`` for partial match - Date fields: Use ``gte.value``, ``lte.value``, etc. - Date range: Use created_at_gt and created_at_lt for between queries # Update a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/update-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json patch /cell_instances/{cell_instance_id} Updates an existing cell instance identified by its ID. Only provided fields will be updated. # Confirm a signed URL upload and finalize the measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/confirm-a-signed-url-upload-and-finalize-the-measurement https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/confirm-upload Confirm a signed URL upload and finalize the measurement. # Confirm extend upload; stitch series and recompute steps Source: https://docs.ionworks.com/api-reference/cell-measurements/confirm-extend-upload;-stitch-series-and-recompute-steps https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/confirm-extend Confirm an extend upload; stitch series and recompute steps. # Delete a specific cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/delete-a-specific-cell-measurement-by-id https://api.ionworks.com/openapi.json delete /cell_measurements/{measurement_id} Deletes a cell measurement identified by its ID, including: - All storage files (steps.parquet, time_series.parquet) - Database record Returns 204 No Content on success or if already deleted. # Download a measurement file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-a-measurement-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/files/{filename} Stream a specific file in a measurement back to the caller. The bytes are proxied through the backend (same-origin) rather than 307-redirecting to the storage signed URL. A cross-origin redirect to ``http://`` storage trips the CSP ``upgrade-insecure-requests`` directive in local dev (and would otherwise require CORS), which breaks the browser-side blob fetch the file gallery does. # Download steps parquet file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-steps-parquet-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps/download Redirect to storage for the steps parquet file. Returns a 307 redirect to a short-lived signed URL. Most HTTP clients follow redirects automatically, so the caller receives the parquet bytes transparently. # Download time series parquet file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-time-series-parquet-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/time_series/download Redirect to storage for the time series parquet file. Returns a 307 redirect to a short-lived signed URL. Most HTTP clients follow redirects automatically, so the caller receives the parquet bytes transparently. Replaces the old ``/time_series/signed_url`` endpoint. # Estimate a running measurement's end time from its remaining protocol Source: https://docs.ionworks.com/api-reference/cell-measurements/estimate-a-running-measurements-end-time-from-its-remaining-protocol https://api.ionworks.com/openapi.json patch /cell_measurements/{measurement_id}/estimate_end_time Forecast when this still-running test will finish. Replays the measured steps onto the protocol's state machine, then simulates only the remainder with the cell specification's default model. Returns immediately, marked ``estimating``; ``estimated_end_time`` lands when the simulation finishes. # Export measurement data (time series, steps, files, metadata) as a zip Source: https://docs.ionworks.com/api-reference/cell-measurements/export-measurement-data-time-series-steps-files-metadata-as-a-zip https://api.ionworks.com/openapi.json post /cell_measurements/export # Get a cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/get-a-cell-measurement-by-id https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id} Retrieve a cell measurement by its ID only. # Get all steps for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-all-steps-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps Retrieve ALL steps for a specific cell measurement. Backend fetches all steps internally (paginating as needed with limit 1000). Returns ``{ steps: {...} }``. # Get cycle metrics only for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-cycle-metrics-only-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/cycles Retrieve cycle metrics only (computed from ALL steps). Backend fetches all steps internally to compute cycles. Returns ``{ cycles: {...} }``, budgeted to ``max_points`` rows when one is given. # Get detailed information for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-detailed-information-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/detail Retrieves detailed information for a specific cell measurement by measurement ID. **Deprecated**: prefer the individual endpoints GET /steps, GET /cycles, GET /time_series, and GET /steps_and_cycles instead. Supports zoom-based resampling: specify x_min, x_max, and x_column to fetch higher resolution data for a specific x-axis range. # Get steps and cycle metrics in one call Source: https://docs.ionworks.com/api-reference/cell-measurements/get-steps-and-cycle-metrics-in-one-call https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps_and_cycles Retrieve all steps and a budgeted cycle series together. Fetches steps once and computes cycles from the same data, avoiding a double fetch when both are needed. Returns ``{ steps: {...}, cycles: {...} }``. ``max_points`` bounds the **cycle** series only. Steps are returned complete whatever it says, and deliberately: the client resolves step-within-cycle filters against this table and builds its hover labels from it, so a decimated steps table would make the same filter select different rows at different plot widths. The cycle series is what gets drawn, and what a long-cycling test can make unbounded. # Get time series data for a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-time-series-data-for-a-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/time_series Return time series data from storage. Does not include steps or cycles. Use GET /steps for steps and GET /cycles for cycle metrics. # Initiate signed URL upload to extend a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/initiate-signed-url-upload-to-extend-a-measurement https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/initiate-extend Mint a signed URL for uploading a delta time series extend file. # List cell measurements Source: https://docs.ionworks.com/api-reference/cell-measurements/list-cell-measurements https://api.ionworks.com/openapi.json get /cell_measurements List cell measurements, scoped to a cell specification or a project. Exactly one of ``cell_specification_id`` (all instances of that spec, page size 1000) or ``project_id`` (paginated, parent instance fields joined, page size 25) is required. ``project_id`` also accepts ``instance_name``, ``cell_instance_id`` and ``spec_id``. ``channel_id`` and ``protocol_id`` are exact match; text takes ``ilike.%value%`` and dates ``gte.value`` / ``lte.value``. # List files in a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/list-files-in-a-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/files Return the list of filenames stored in a file-type measurement. # Stop watching a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/stop-watching-a-measurement https://api.ionworks.com/openapi.json delete /cell_measurements/{measurement_id}/watch Stop watching a measurement for the current user. Idempotent -- unwatching something not watched returns 204 all the same. # Update a cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/update-a-cell-measurement-by-id https://api.ionworks.com/openapi.json patch /cell_measurements/{measurement_id} Update an existing cell measurement's metadata. Only metadata fields can be updated (name, protocol, test_setup, notes, properties, channel_id, protocol_id). Time series and steps data cannot be modified. # Watch a measurement (Lab 'My Channels') Source: https://docs.ionworks.com/api-reference/cell-measurements/watch-a-measurement-lab-my-channels https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/watch Start watching a measurement for the current user. Idempotent -- watching an already-watched measurement is a no-op. Watching the live measurement on a channel is how a user adds that channel to their "My Channels" view. # Create a new cell instance for a cell specification Source: https://docs.ionworks.com/api-reference/cell-specifications/create-a-new-cell-instance-for-a-cell-specification https://api.ionworks.com/openapi.json post /cell_specifications/{cell_spec_id}/cell_instances Creates a new cell instance under the specified cell specification (by ID). Returns the created cell instance and sets the Location header. # Create a new cell specification with nested component/material data Source: https://docs.ionworks.com/api-reference/cell-specifications/create-a-new-cell-specification-with-nested-componentmaterial-data https://api.ionworks.com/openapi.json post /cell_specifications Create a new cell specification with nested component and material data. Materials and components are automatically upserted based on uniqueness: - Materials: unique on (organization_id, name, manufacturer) - Components: unique on (organization_id, component_type, material_id, properties) Example request body: ```json { "name": "NCM622/Graphite Coin Cell", "capacity": 0.002, "lower_voltage_cutoff": 2.5, "upper_voltage_cutoff": 4.2, "form_factor": "R2032", "anode": { "properties": {"diameter_mm": 15, "thickness_um": 23}, "material": {"name": "Graphite", "manufacturer": "Customcells"} }, "cathode": { "properties": {"diameter_mm": 14, "thickness_um": 22}, "material": { "name": "NCM622", "definition": {"formula": "LiNi0.6Co0.2Mn0.2O2"} } } } ``` # Delete a cell specification by id for the current organization Source: https://docs.ionworks.com/api-reference/cell-specifications/delete-a-cell-specification-by-id-for-the-current-organization https://api.ionworks.com/openapi.json delete /cell_specifications/{cell_spec_id} Delete a cell specification and all its instances, measurements, and storage files. # Get a cell specification with nested component and material data Source: https://docs.ionworks.com/api-reference/cell-specifications/get-a-cell-specification-with-nested-component-and-material-data https://api.ionworks.com/openapi.json get /cell_specifications/{cell_spec_id} Retrieve a cell specification with nested component and material information. Returns the specification with anode, cathode, electrolyte, separator, and case components, each including their material details. # List cell instances for a cell specification Source: https://docs.ionworks.com/api-reference/cell-specifications/list-cell-instances-for-a-cell-specification https://api.ionworks.com/openapi.json get /cell_specifications/{cell_spec_id}/cell_instances List cell instances for a specification with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, created_by_email): Use ``ilike.%value%`` for partial match - Date fields: Use ``gte.value``, ``lte.value``, etc. - Date range: Use created_at_gt and created_at_lt for between queries # List cell specifications for the current organization Source: https://docs.ionworks.com/api-reference/cell-specifications/list-cell-specifications-for-the-current-organization https://api.ionworks.com/openapi.json get /cell_specifications List cell specifications with pagination and optional filtering. With ``project_id``, only specs linked to that project; without it, every spec in the organization. Material reverse-lookup: ``material_id``/``material_ids`` search all slots, a per-slot ``_material_id`` only that slot, and ``exclude_cell_spec_id`` drops one spec. These take precedence over the text/date filters, which use Supabase operators (``ilike.%value%``, ``gte.value``); ``created_at_gt``/ ``_lt`` bound a range. ``q`` is the search box's free-text param. # Update a cell specification with nested component/material data Source: https://docs.ionworks.com/api-reference/cell-specifications/update-a-cell-specification-with-nested-componentmaterial-data https://api.ionworks.com/openapi.json patch /cell_specifications/{cell_spec_id} Update an existing cell specification with nested component and material data. Materials and components are automatically upserted based on uniqueness. Only provided fields will be updated. Example request body: ```json { "capacity": 0.003, "anode": { "properties": {"diameter_mm": 16, "thickness_um": 25}, "material": {"name": "Graphite", "manufacturer": "NewSupplier"} } } ``` # Delete a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/delete-a-specific-channel-by-id https://api.ionworks.com/openapi.json delete /channels/{channel_id} Delete an existing channel identified by its ID. Deleting a channel nulls ``cell_measurements.channel_id`` on any referencing measurements (they are not deleted). # Get a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/get-a-specific-channel-by-id https://api.ionworks.com/openapi.json get /channels/{channel_id} Retrieve a specific channel by its ID, scoped to the selected org. # List a channel's out-of-service history Source: https://docs.ionworks.com/api-reference/channels/list-a-channels-out-of-service-history https://api.ionworks.com/openapi.json get /channels/{channel_id}/incidents List the channel's outage spans, most recently started first. An open incident (``resolved_at`` null) is the channel's current outage; at most one can exist. Rows with ``is_estimated`` true were reconstructed from the channel's last-modified time when outage history was introduced, so their ``started_at`` is an upper bound rather than a recorded event. # List channels Source: https://docs.ionworks.com/api-reference/channels/list-channels https://api.ionworks.com/openapi.json get /channels List channels with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries ``q`` is the free-text search box's param: it narrows on channel name substring plus a prefix full-text match over ``search_vector``, and composes with the column filters above. # Update a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/update-a-specific-channel-by-id https://api.ionworks.com/openapi.json patch /channels/{channel_id} Update an existing channel identified by its ID. Only provided fields will be updated. Taking a channel out of service (or returning it) also records an outage span; ``user_id`` is threaded through so the incident records who did it. # Create a new model within an organization Source: https://docs.ionworks.com/api-reference/create-a-new-model-within-an-organization https://api.ionworks.com/openapi.json post /models Create a new model in the specified organization. # Create a new project within an organization, assigning creator as Project Admin Source: https://docs.ionworks.com/api-reference/create-a-new-project-within-an-organization-assigning-creator-as-project-admin https://api.ionworks.com/openapi.json post /projects # Create a new channel for a cycler Source: https://docs.ionworks.com/api-reference/cyclers/create-a-new-channel-for-a-cycler https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/channels Create a new channel under the specified cycler (by ID). Returns the created channel and sets the Location header. # Delete a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/delete-a-specific-cycler-by-id https://api.ionworks.com/openapi.json delete /cyclers/{cycler_id} Delete an existing cycler identified by its ID. Foreign-key cascade drops child channels (cyclers -> channels). # Get a cycler's open service event, if any Source: https://docs.ionworks.com/api-reference/cyclers/get-a-cyclers-open-service-event-if-any https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/service_events/open Return the cycler's current service, or ``null`` when it is in service. A dedicated read rather than making the caller scan the history: that list is ordered newest-created first and paginated, so an event recorded after the fact sorts ahead of an older open one and would eventually hide it — the cycler would read as in service while it is out. At most one open event can exist, so this is exact. # Get a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/get-a-specific-cycler-by-id https://api.ionworks.com/openapi.json get /cyclers/{cycler_id} Retrieve a specific cycler by its ID, scoped to the selected org. # List a cycler's service history Source: https://docs.ionworks.com/api-reference/cyclers/list-a-cyclers-service-history https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/service_events List the cycler's service events, most recently created first. An event with a null ``performed_at`` is the cycler's current service; at most one can exist. Completing a ``calibration`` event is what advances the cycler's ``last_calibrated_at`` and therefore its ``calibration_due_at``. # List channels for a cycler Source: https://docs.ionworks.com/api-reference/cyclers/list-channels-for-a-cycler https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/channels List channels for a cycler with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries ``q`` is the free-text search box's param: it narrows on channel name substring plus a prefix full-text match over ``search_vector``, and composes with the column filters above. # List cyclers Source: https://docs.ionworks.com/api-reference/cyclers/list-cyclers https://api.ionworks.com/openapi.json get /cyclers List cyclers with pagination and filtering. Filters follow each field's policy in ``_CYCLER_FILTERS``: text fields take ``ilike.%value%``, ``site_id``/``project_id`` are exact-only, date fields take ``gte.value``/``lte.value``, ``created_at_gt``/``_lt`` bound a range, and ``calibration_due_at_lt`` finds cyclers due before a date. ``q`` is the search box's free-text param and composes with all of them. # Record a cycler's open service as done Source: https://docs.ionworks.com/api-reference/cyclers/record-a-cyclers-open-service-as-done https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/service_events/complete Record service as done on a cycler. Two paths behind one endpoint, chosen by whether the cycler is currently out of service: - **Open event** (it was taken out first): that event is completed and every channel incident pointing at it is closed in one write, so the cycler cannot end up half returned to service. - **No open event**: the common case of "I calibrated this, log it". A single already-completed event is recorded from the request's ``event_type``. Nothing was taken out, so nothing needs returning, and no one has to open an event purely so it can be closed. Either way a ``calibration`` advances ``last_calibrated_at`` — the only supported way that field moves — and optionally updates ``calibration_interval_days``. Addressed by cycler rather than by event id because a cycler has at most one open event: the caller already knows which cycler it serviced, and looking up the event id first would be a round-trip that proves nothing. # Take a cycler out of service for planned work Source: https://docs.ionworks.com/api-reference/cyclers/take-a-cycler-out-of-service-for-planned-work https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/service_events Open a service event, taking every channel on the cycler out of service. One call replaces taking each channel out individually: calibration is performed on the instrument, so the whole cycler goes down as one event and comes back as one. Channels already out of service keep their existing incident (a broken relay is more specific than "being serviced"). Rejected with 409 if the cycler already has an open event, or if any channel still has a running measurement. # Update a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/update-a-specific-cycler-by-id https://api.ionworks.com/openapi.json patch /cyclers/{cycler_id} Update an existing cycler identified by its ID. Only provided fields will be updated. # Delete a model from an organization Source: https://docs.ionworks.com/api-reference/delete-a-model-from-an-organization https://api.ionworks.com/openapi.json delete /models/{model_id} Delete a model, validating it belongs to the organization. # Delete a project from an organization Source: https://docs.ionworks.com/api-reference/delete-a-project-from-an-organization https://api.ionworks.com/openapi.json delete /projects/{project_id} # Get a specific model by ID within an organization Source: https://docs.ionworks.com/api-reference/get-a-specific-model-by-id-within-an-organization https://api.ionworks.com/openapi.json get /models/{model_id} Get a specific model by ID with config. Validates it belongs to the organization or is a system model. # Get a specific project by ID within an organization Source: https://docs.ionworks.com/api-reference/get-a-specific-project-by-id-within-an-organization https://api.ionworks.com/openapi.json get /projects/{project_id} # List models for an organization Source: https://docs.ionworks.com/api-reference/list-models-for-an-organization https://api.ionworks.com/openapi.json get /models List models for an organization with pagination, filtering, and sorting. Filter parameters support Supabase filter operators, per the field's declared policy: - Text fields (name, description, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # List projects for an organization Source: https://docs.ionworks.com/api-reference/list-projects-for-an-organization https://api.ionworks.com/openapi.json get /projects List projects for an organization with pagination, filtering, and sorting. Filter parameters support Supabase filter operators, per the field's declared policy: - Text fields (name, description): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries There is no ``created_by_email`` filter: the projects table records no creator, so there is nothing to match against. # Create a new parameterized model for the current cell specification Source: https://docs.ionworks.com/api-reference/parameterized_models/create-a-new-parameterized-model-for-the-current-cell-specification https://api.ionworks.com/openapi.json post /cells/{cell_spec_id}/parameterized_models # Create a new parameterized model for the current cell specification Source: https://docs.ionworks.com/api-reference/parameterized_models/create-a-new-parameterized-model-for-the-current-cell-specification-1 https://api.ionworks.com/openapi.json post /parameterized_models # Delete a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/delete-a-parameterized-model https://api.ionworks.com/openapi.json delete /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Delete a parameterized model by its ID for a given cell specification. # Get a parameterized model by id Source: https://docs.ionworks.com/api-reference/parameterized_models/get-a-parameterized-model-by-id https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Get a single parameterized model (with its model info) by id. Lets clients fetch one model directly instead of listing every model in a project. Access is scoped by row-level security via the requesting user's client. # Get a parameterized model by id Source: https://docs.ionworks.com/api-reference/parameterized_models/get-a-parameterized-model-by-id-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id} Get a single parameterized model (with its model info) by id. Lets clients fetch one model directly instead of listing every model in a project. Access is scoped by row-level security via the requesting user's client. # Get Parameterized Model Parameter Values Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-parameter-values https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/parameter-values Get all parameter values from a parameterized model as JSON. This endpoint returns the complete parameter set from a parameterized model, suitable for use as baseline parameters in DataFit, parameterization, or optimization workflows. Version and citation metadata are excluded. Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch parameters from parameterized_models_repository : ParameterizedModelsRepository Repository for parameterized model data access Returns ------- dict All parameterized model parameter values as JSON (excluding version and citations) # Get Parameterized Model Spatial Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-spatial-variable-names https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/spatial-variable-names Get variables that can be rendered as one-dimensional spatial profiles. # Get Parameterized Model Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-variable-names https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/variable-names Get variable names where domain is empty or current collector. This endpoint returns the names of all variables in the model where the domain is empty or "current collector" (i.e. they are functions of time only). Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch variables from supabase : Client Supabase client for database access Returns ------- list[str] List of variable names with empty or current collector domain # List parameterized models for a cell specification or project Source: https://docs.ionworks.com/api-reference/parameterized_models/list-parameterized-models-for-a-cell-specification-or-project https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models List parameterized models with pagination, filtering, and sorting, scoped by cell spec or project. Provide exactly one scope: - ``cell_spec_id`` lists models for a single cell specification. This is the path parameter when called via ``/cells/{cell_spec_id}/parameterized_models``. - ``project_id`` lists models across every cell specification linked to the project. ``cell_spec_id`` may additionally be passed to narrow a project-scoped query to one cell specification. Parameters ---------- model_id : str | None Base model to narrow to, matched exactly. Composes with either scope — e.g. every parameterized model in a project that derives from one model. cell_spec_id : str | None Cell specification to scope to. Required when ``project_id`` is not given. project_id : str | None Project to scope to. When set, ``cell_spec_id`` is an optional further filter rather than the primary scope. name : str | None Column filter on the model name. Applies to both scopes. Values use Supabase operator syntax and are validated by the field policy — e.g. ``ilike.%alpha%`` for a case-insensitive substring match. A bare value falls through to an exact match. description : str | None Column filter on the model description. Same operator-syntax semantics as ``name`` and likewise applies to both scopes. q : str | None Free-text search on the model name plus a prefix full-text match, matching the global search. Applies to both scopes and composes with the ``name`` / ``description`` column filters. order_by : {"name", "created_at"} Column to sort the cell-spec-scoped results by. Defaults to ``"created_at"``. order : {"asc", "desc"} Sort direction for the cell-spec-scoped results. Defaults to ``"desc"``. limit : int Maximum number of models to return per page (1-1000). Defaults to 100. The upper bound is 1000 so callers (e.g. the optimization clone form) can load every model for a project in one request. offset : int Number of models to skip for pagination. Defaults to 0. # List parameterized models for a cell specification or project Source: https://docs.ionworks.com/api-reference/parameterized_models/list-parameterized-models-for-a-cell-specification-or-project-1 https://api.ionworks.com/openapi.json get /parameterized_models List parameterized models with pagination, filtering, and sorting, scoped by cell spec or project. Provide exactly one scope: - ``cell_spec_id`` lists models for a single cell specification. This is the path parameter when called via ``/cells/{cell_spec_id}/parameterized_models``. - ``project_id`` lists models across every cell specification linked to the project. ``cell_spec_id`` may additionally be passed to narrow a project-scoped query to one cell specification. Parameters ---------- model_id : str | None Base model to narrow to, matched exactly. Composes with either scope — e.g. every parameterized model in a project that derives from one model. cell_spec_id : str | None Cell specification to scope to. Required when ``project_id`` is not given. project_id : str | None Project to scope to. When set, ``cell_spec_id`` is an optional further filter rather than the primary scope. name : str | None Column filter on the model name. Applies to both scopes. Values use Supabase operator syntax and are validated by the field policy — e.g. ``ilike.%alpha%`` for a case-insensitive substring match. A bare value falls through to an exact match. description : str | None Column filter on the model description. Same operator-syntax semantics as ``name`` and likewise applies to both scopes. q : str | None Free-text search on the model name plus a prefix full-text match, matching the global search. Applies to both scopes and composes with the ``name`` / ``description`` column filters. order_by : {"name", "created_at"} Column to sort the cell-spec-scoped results by. Defaults to ``"created_at"``. order : {"asc", "desc"} Sort direction for the cell-spec-scoped results. Defaults to ``"desc"``. limit : int Maximum number of models to return per page (1-1000). Defaults to 100. The upper bound is 1000 so callers (e.g. the optimization clone form) can load every model for a project in one request. offset : int Number of models to skip for pagination. Defaults to 0. # Update a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/update-a-parameterized-model https://api.ionworks.com/openapi.json patch /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Update a parameterized model's name and/or description. # Create a new program Source: https://docs.ionworks.com/api-reference/programs/create-a-new-program https://api.ionworks.com/openapi.json post /programs Create a new lab test program for the current organization. Returns the created program and sets the Location header. # Delete a specific program by id Source: https://docs.ionworks.com/api-reference/programs/delete-a-specific-program-by-id https://api.ionworks.com/openapi.json delete /programs/{program_id} Delete an existing lab test program identified by its ID. Planned measurements and cell measurements referencing this program have their ``program_id`` set to null (``ON DELETE SET NULL``); they are not otherwise affected. # Get a specific program by id Source: https://docs.ionworks.com/api-reference/programs/get-a-specific-program-by-id https://api.ionworks.com/openapi.json get /programs/{program_id} Retrieve a specific lab test program by its ID, scoped to the org. # List programs Source: https://docs.ionworks.com/api-reference/programs/list-programs https://api.ionworks.com/openapi.json get /programs List lab test programs for the current organization, with pagination. # Update a specific program by id Source: https://docs.ionworks.com/api-reference/programs/update-a-specific-program-by-id https://api.ionworks.com/openapi.json patch /programs/{program_id} Update an existing lab test program identified by its ID. Only provided fields will be updated. # Remove a user from a project Source: https://docs.ionworks.com/api-reference/remove-a-user-from-a-project https://api.ionworks.com/openapi.json delete /projects/{project_id}/members/{user_id} Remove a user from a project. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. # Create Simulation Batch With Template Source: https://docs.ionworks.com/api-reference/simulations/create-simulation-batch-with-template https://api.ionworks.com/openapi.json post /simulations/with-template/batch Optimized batch simulation creation with template-based experiments. This endpoint is for creating multiple simulations using template-based experiments with DOE support. For protocol-based batch simulations, use POST /simulations/batch. Returns ------- List[SimulationCreationResponse] List of simulation creation responses with IDs, job info, and status Raises ------ HTTPException If usage limit reached or job submission fails # Get Simulation Source: https://docs.ionworks.com/api-reference/simulations/get-simulation https://api.ionworks.com/openapi.json get /simulations/{simulation_id} # Get Simulation Cycles Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-cycles https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/cycles Get cycle-level metrics for a simulation. Computes metrics like discharge capacity, coulombic efficiency, capacity retention, etc. for each cycle from the steps.parquet file using ionworksdata.cycle_metrics.get_cycle_metrics. Parameters ---------- simulation_id : str ID of the simulation user_org_client : tuple[str, str | None, AsyncClient] Current user_id, organization_id, and Supabase client simulation_repository : SimulationRepository Repository for simulations table simulation_data_repository : SimulationDataRepository Repository for simulation_data table Returns ------- dict Dictionary with cycle metrics as lists, one value per cycle. Keys include: Cycle number, Discharge capacity [A.h], Charge capacity [A.h], Coulombic efficiency, Capacity retention, etc. Raises ------ HTTPException 404 if no results found, 500 for computation errors # Get Simulation Result Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-result https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/result Get stored results for a simulation. Handles multiple data formats: 1. Parquet files in storage (newest format) - loaded from storage 2. JSONB in simulation_data table (legacy) - migrated to parquet Parameters ---------- simulation_id : str ID of the simulation user_org_client : tuple[str, str | None, AsyncClient] Current user_id, organization_id, and Supabase client simulation_repository : SimulationRepository Repository for simulations table simulation_data_repository : SimulationDataRepository Repository for simulation_data table (legacy) target_rows : int | None If set, downsample time series to at most this many rows. x_min : float | None Minimum Time [s] value to filter (for zoom). x_max : float | None Maximum Time [s] value to filter (for zoom). Returns ------- dict Dictionary with time_series, steps, and metrics Raises ------ HTTPException 404 if no results found, 400 for other errors # Get Simulation Spatial Profile Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-spatial-profile https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/spatial-profile Evaluate a one-dimensional spatial field from stored solution chunks. # Get Simulation Summary Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-summary https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/summary Fetch a simulation summary without simulation_data. Suitable for list view refresh. # List Simulations Source: https://docs.ionworks.com/api-reference/simulations/list-simulations https://api.ionworks.com/openapi.json get /simulations List simulations filtered by a parameterized model, study, or protocol. Exactly one of ``parameterized_model_id``, ``study_id``, or ``experiment_template_id`` must be provided. Returns simulation summaries without simulation_data, plus a map of model scalar parameters deduplicated by parameterized_model_id. Parameters ---------- parameterized_model_id : str, optional Filter simulations belonging to this parameterized model. study_id : str, optional Filter simulations assigned to this study. experiment_template_id : str, optional Filter simulations whose experiment references this protocol template. limit : int, optional Maximum number of results to return. Must be between 1 and 1000. Defaults to 100. offset : int, optional Number of results to skip for pagination. Must be >= 0. Defaults to 0. status : str, optional PostgREST filter expression on the simulation's job status, e.g. ``"eq.completed"`` or ``"in.(pending,processing,waiting)"``. When provided, only simulations whose job matches are returned. model_name, protocol_name : str, optional Exact-match expressions (e.g. ``"eq.My model"``) on the joined parameterized model name and protocol (experiment template) name. A simulation has no name of its own, so these two are what identify it. created_at_gt, created_at_lt, updated_at_gt, updated_at_lt : str, optional ISO datetime strings for date-range (between) filtering on the native ``created_at`` / ``updated_at`` columns. Returns ------- ListSimulationsResponse Simulations, model scalar parameters map, and total count. # Create a new cycler for a site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-cycler-for-a-site https://api.ionworks.com/openapi.json post /sites/{site_id}/cyclers Create a new cycler under the specified site (by ID). The request body must include ``project_id`` — the project that will own this cycler. The site is org-scoped (cross-project), so any of the org's sites may host a cycler for any of its projects. Returns the created cycler and sets the Location header. # Create a new site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-site https://api.ionworks.com/openapi.json post /sites Create a new site for the current organization. Returns the created site and sets the Location header. # Create a new thermal chamber for a site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-thermal-chamber-for-a-site https://api.ionworks.com/openapi.json post /sites/{site_id}/thermal_chambers Create a new thermal chamber under the specified site (by ID). The request body must include ``project_id`` — the project that will own this chamber — and both temperature bounds. The site is org-scoped (cross-project), so any of the org's sites may host a chamber for any of its projects. Returns the created chamber and sets the Location header. # Delete a specific site by id Source: https://docs.ionworks.com/api-reference/sites/delete-a-specific-site-by-id https://api.ionworks.com/openapi.json delete /sites/{site_id} Delete an existing site identified by its ID. Foreign-key cascade drops child cyclers and their channels (sites -> cyclers -> channels). # Get a specific site by id Source: https://docs.ionworks.com/api-reference/sites/get-a-specific-site-by-id https://api.ionworks.com/openapi.json get /sites/{site_id} Retrieve a specific site by its ID, scoped to the selected org. # List cyclers for a site Source: https://docs.ionworks.com/api-reference/sites/list-cyclers-for-a-site https://api.ionworks.com/openapi.json get /sites/{site_id}/cyclers List cyclers for a site with pagination and filtering. Filters follow each field's policy in ``_CYCLER_FILTERS``: text fields take ``ilike.%value%``, ``project_id`` is exact-only, date fields take ``gte.value``/``lte.value``, ``created_at_gt``/``_lt`` bound a range, and ``calibration_due_at_lt`` finds cyclers due before a date. ``q`` is the search box's free-text param and composes with all of them. # List sites Source: https://docs.ionworks.com/api-reference/sites/list-sites https://api.ionworks.com/openapi.json get /sites List sites with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, location, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # List thermal chambers for a site Source: https://docs.ionworks.com/api-reference/sites/list-thermal-chambers-for-a-site https://api.ionworks.com/openapi.json get /sites/{site_id}/thermal_chambers List thermal chambers for a site with pagination and filtering. Filter parameters support Supabase filter operators, per the field's declared policy in ``_THERMAL_CHAMBER_FILTERS``: - Text fields (name, id_text, manufacturer, model, serial_number, created_by_email): use ``ilike.%value%`` for partial match - Exact fields (project_id): literal equality; operator syntax is rejected - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # Update a specific site by id Source: https://docs.ionworks.com/api-reference/sites/update-a-specific-site-by-id https://api.ionworks.com/openapi.json patch /sites/{site_id} Update an existing site identified by its ID. Only provided fields will be updated. # Delete a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/delete-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json delete /thermal_chambers/{thermal_chamber_id} Delete a thermal chamber by its ID. Nothing references a thermal chamber yet, so the delete is unconditional — there is no child row to cascade and no measurement history to strand. # Get a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/get-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json get /thermal_chambers/{thermal_chamber_id} Retrieve a specific thermal chamber by its ID, scoped to the selected org. # List thermal chambers Source: https://docs.ionworks.com/api-reference/thermal-chambers/list-thermal-chambers https://api.ionworks.com/openapi.json get /thermal_chambers List thermal chambers with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, manufacturer, model, serial_number, created_by_email): use ``ilike.%value%`` for partial match - Exact-match fields (site_id, project_id): equality filter - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # Update a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/update-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json patch /thermal_chambers/{thermal_chamber_id} Update an existing thermal chamber identified by its ID. Only provided fields will be updated. The service validates the *merged* temperature bounds against the stored row, so a PATCH sending only one bound cannot leave the chamber with an inverted range. # Update a model within an organization Source: https://docs.ionworks.com/api-reference/update-a-model-within-an-organization https://api.ionworks.com/openapi.json patch /models/{model_id} Update a model, validating it belongs to the organization. # Update a project within an organization Source: https://docs.ionworks.com/api-reference/update-a-project-within-an-organization https://api.ionworks.com/openapi.json patch /projects/{project_id} # Update a user's project role Source: https://docs.ionworks.com/api-reference/update-a-users-project-role https://api.ionworks.com/openapi.json patch /projects/{project_id}/members/{user_id} Update the project role for an existing member. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. # Design Optimization Source: https://docs.ionworks.com/guide/optimize/design-optimization Mathematical formulation of battery design optimization: parameters, metrics, actions, penalty constraints, and multi-objective tradeoffs Design optimization finds battery design parameters that meet performance objectives without requiring experimental data. Instead of fitting a model to measurements, you define what you want to achieve and let the optimizer explore the design space through repeated simulation. ## How It Works ```mermaid theme={null} flowchart LR subgraph Optimizer P[Parameters] end subgraph Simulation M[Model] --> S[Solution] end subgraph Evaluation S --> V[Variables] V --> Met[Metrics] Met --> A[Actions] A --> C[Cost] end P --> M C --> P ``` The optimization loop: 1. **Parameters** define the design variables being optimized (e.g., electrode thickness, porosity) 2. **Model** runs a simulation with those parameters under specified operating conditions 3. **Variables** are time-series outputs from the simulation (voltage, temperature, capacity) 4. **Metrics** extract scalar values from variables (final voltage, maximum temperature, mean current) 5. **Actions** define what to do with metrics (maximize, minimize, constrain) 6. **Cost** combines action results into a single objective for the optimizer ## Design Parameters Design parameters are the degrees of freedom the optimizer is allowed to vary. Each parameter has a feasible range (bounds) set by manufacturing constraints or material properties — for example, electrode thickness might be bounded between $25\,\mu\text{m}$ and $150\,\mu\text{m}$, and active material volume fraction between $0.4$ and $0.85$. Choosing parameters well is more important than choosing many: two or three influential parameters with realistic bounds usually yields more insight than a dozen loosely-bounded ones. Where parameters are physically coupled — porosity and active material fraction must sum to one, for instance — the coupling should be encoded in the problem definition rather than left to the optimizer to discover. ## Metrics Metrics transform simulation time-series into scalar values that can be optimized. They answer questions like "What is the voltage at the end of discharge?" or "What is the maximum temperature?" ### Point metrics Extract a value at a specific condition in the simulation: | Metric | Extracts value at... | Example | | ------- | ------------------------ | ------------------------- | | Time | Specific time point | Voltage at $t = 100$ s | | SOC | Specific state of charge | Voltage at 50% SOC | | Voltage | Specific voltage | Capacity when $V = 3.0$ V | ### Aggregation metrics Compute statistics over the entire solution: | Metric | Computes | Example | | ------- | ----------------- | ------------------------- | | Mean | Average value | Mean current during pulse | | Maximum | Peak value | Maximum temperature | | Minimum | Lowest value | Minimum voltage | | Sum | Accumulated total | Total energy | ### Composed metrics Derived quantities are built by combining primitive metrics with arithmetic. For example, pulse resistance is the voltage change divided by the current: $$ R = \frac{V_{\text{after}} - V_{\text{before}}}{\bar{I}} $$ ### Step and cycle metrics Experiments with multiple steps or cycles need metrics that unroll along that axis — e.g. capacity measured at the end of the discharge step of every cycle, yielding a capacity-fade curve rather than a single number. ## Actions Actions define how the optimizer should treat each metric: | Action | Behavior | | ----------- | ---------------------------------------------- | | Maximize | Maximize the metric value | | Minimize | Minimize the metric value | | GreaterThan | Constraint: metric must exceed a threshold | | LessThan | Constraint: metric must stay below a threshold | Constraints are typically enforced through **penalty functions**: when the constraint is violated, a large term is added to the cost proportional to the amount of violation. This converts a constrained problem into an unconstrained one that any cost-minimizing optimizer can handle. ### Multi-objective optimization Most design problems trade off competing goals — energy vs. power, capacity vs. charge time, performance vs. temperature. There are two ways to combine them: * **Weighted sum**: pick weights $w_i$ and optimize $\sum_i w_i f_i$. Simple, but the weights implicitly encode a preference and can hide Pareto tradeoffs. * **Constraint-based**: maximize the primary objective subject to the others being bounded (e.g. maximize energy density subject to $T_{\max} \leq 50\,^{\circ}\text{C}$). Often more interpretable because the constraint thresholds map directly to design requirements. ## Example: Maximizing Energy Density This example illustrates the full workflow on a classic battery design problem. ### Problem description Electrode thickness presents a fundamental design tradeoff: * **Thicker electrodes** increase capacity (more active material per unit area) but worsen rate capability because lithium must diffuse further * **Thinner electrodes** improve power delivery but reduce total energy storage We seek the thickness that maximizes energy density at a given discharge rate, subject to a temperature limit. ### Mathematical formulation The optimization problem is: $$ \max_{L_{\text{pos}}} \; E_g(L_{\text{pos}}) $$ subject to: $$ L_{\text{pos}}^{\min} \leq L_{\text{pos}} \leq L_{\text{pos}}^{\max} $$ $$ T_{\max}(L_{\text{pos}}) \leq T_{\text{limit}} $$ where $L_{\text{pos}}$ is the positive electrode thickness, $E_g$ is the gravimetric energy density at end of discharge, $T_{\max}$ is the maximum cell temperature during discharge, and $T_{\text{limit}}$ is the temperature safety limit (e.g. $323\,\text{K}$). The energy density is computed from the simulation as: $$ E_g = \frac{\int_0^{t_f} V(t) \cdot I(t) \, dt}{m_{\text{cell}}} $$ where $V(t)$ is voltage, $I(t)$ is current (positive = discharge), $t_f$ is the discharge end time, and $m_{\text{cell}}$ is the cell mass. ### What the optimizer does For each candidate thickness, a simulation is run and the metrics are evaluated: 1. The model solves the electrochemical equations to get $V(t)$, $T(t)$, etc. 2. A point metric at $t_f$ extracts the final energy density 3. An aggregation metric extracts the peak temperature 4. The actions convert these into a cost: negative $E_g$ (for maximization) plus a penalty if $T_{\max} > T_{\text{limit}}$ 5. The optimizer uses this cost to propose the next candidate The converged solution balances energy density against thermal constraints — thicker electrodes store more energy but generate more heat from ohmic and kinetic losses. ## Choosing an Optimizer Design optimization is a black-box problem: each evaluation is a full simulation, so optimizer choice mostly depends on simulation cost, parameter count, and available parallelism. | Situation | Recommended approach | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Cheap, fast simulations with many parameters | Evolutionary search (differential evolution) with a small population over many generations | | Expensive simulations, ten or fewer parameters, small budget | Bayesian optimization — a surrogate model makes every evaluation count | | Expensive simulations evaluated in parallel at wide batch sizes | Trust-region Bayesian optimization (TuRBO) — scales surrogate efficiency to large batches and higher dimensions | | No strong prior on problem structure | CMA-ES, a robust general-purpose default | Surrogate methods (Bayesian optimization, TuRBO, SOBER) do more work per round to save expensive simulations. Population methods (CMA-ES, differential evolution, particle swarm) are cheaper per iteration and suit faster objectives. When simulations run in parallel, wall-clock time is driven by the number of optimization rounds rather than the total number of evaluations. TuRBO is designed for that regime; set its warm-up size to the batch width so the first round uses the available workers. SOBER is useful when you want quadrature-style batch selection. For pure optimization, prefer TuRBO, Bayesian optimization, or differential evolution. ## Best practices **Parameter selection** — Choose parameters that have significant influence on the objective. Use bounds that reflect physical and manufacturing limits, and link coupled parameters (e.g. porosity $= 1 - $ active-material fraction) rather than treating them as independent. **Operating conditions** — Simulate at conditions that reflect actual usage. A cell optimized for 1C discharge may perform poorly at 5C, and vice versa. Include rest periods and realistic duty cycles when they matter. **Start simple** — Two or three parameters with a single clear objective will teach you more about the design space than a high-dimensional problem that takes hours to converge. Add complexity only once the simple version behaves as expected. **Validate physically** — The optimizer will happily return mathematically optimal parameters that are physically unreasonable. Always sanity-check the solution against physical intuition and known design constraints. # Terminology & Standards Source: https://docs.ionworks.com/guide/reference/terminology Battery modeling terms and PyBaMM sign conventions: anode/cathode, lithiation, capacity, current direction, stoichiometry, and SOC Battery modeling terminology varies significantly across contexts. This page defines how terms are used throughout this guide, along with our chosen conventions where multiple standards exist. ## Terminology | Term | Definition | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | **Anode** | The electrode with the lower open-circuit potential, often graphite, silicon, or lithium. Commonly referred to as the "negative electrode". | | **Cathode** | The electrode with the higher open-circuit potential, e.g., NMC or LFP. Commonly referred to as the "positive electrode". | | **Negative electrode** | Used interchangeably with "anode". | | **Positive electrode** | Used interchangeably with "cathode". | | **Capacity** | Either the total available capacity of an electrode or cell (denoted $Q$), or the instantaneous capacity during operation (denoted $q$). | | **Lithiation** | The amount of lithium intercalated relative to the minimum and maximum possible lithium content. Bounded between 0 and 1. | | **Stoichiometry** | Used interchangeably with "lithiation". | | **Nominal capacity** | The rated capacity of the cell. | | **Theoretical capacity** | The total capacity extractable at open-circuit voltage (infinitely slow discharge) between voltage limits. | | **Potential** | The electric potential of a single electrode relative to metallic lithium (0V). | | **Voltage** | The difference between positive and negative electrode potentials. | ## Standards ### Direction of Current Following the PyBaMM convention, **positive current corresponds to discharge** and **negative current corresponds to charge**. The discharge capacity is given by: $$ q_{\text{dchg}}(t) = Q_0 + \int I(t) \, dt $$ where $Q_0$ is the starting capacity (equal to 0 if the cell is at 100% SOC and $Q_{\text{cell}}$ if the cell is at 0% SOC). The charging capacity is defined as: $$ q_{\text{chg}}(t) = -q_{\text{dchg}}(t) $$ Lower case $q$ indicates a quantity that varies during operation, while capital $Q$ represents a scalar property of the electrodes or cell. ### Single Electrode For a single electrode, we say that the electrode is **"charged"** when its lithiation/stoichiometry/capacity increases. The instantaneous capacity of the electrode is defined by $q^{\text{elec}}(t)$. The mathematical definition depends on whether the electrode is the anode or cathode of a full cell (see below), but in general $q^{\text{elec}}$ equals $q_{\text{chg}}$ (or $q_{\text{dchg}}$) plus an offset. We can express this in terms of electrode lithiation/stoichiometry: $$ \theta(t) = \frac{q^{\text{elec}}(t)}{Q^{\text{tot}}} $$ where $Q^{\text{tot}}$ is the total capacity of the electrode. | Variable | Meaning | | ----------------- | ------------------------------------------- | | $q^{\text{elec}}$ | Instantaneous capacity of an electrode | | $Q^{\text{tot}}$ | Total capacity of the electrode | | $\theta(t)$ | Instantaneous stoichiometry of an electrode | ### Whole Cell The negative and positive electrodes behave differently when combined in a full cell. During discharge: * **Negative electrode**: Lithiation decreases → open-circuit potential increases * **Positive electrode**: Lithiation increases → open-circuit potential decreases The open-circuit voltage of the full cell is: $$ U = U_p - U_n $$ which is monotonically decreasing since $U_p$ is decreasing and $U_n$ is increasing. #### Capacity Relationships Defining min/max electrode capacities by $Q^{\min/\max}$, and electrode capacities at 0%/100% cell SOC by $Q^{0/100}$: $$ \begin{aligned} Q_n^{\min} &= Q_n^0 \\ Q_n^{\max} &= Q_n^{100} \\ Q_p^{\max} &= Q_p^0 \\ Q_p^{\min} &= Q_p^{100} \end{aligned} $$ The electrode instantaneous capacity is: $$ q_n^{\text{elec}}(t) = Q_n^{100} - q_{\text{dchg}} $$ for the negative electrode and: $$ q_p^{\text{elec}}(t) = Q_p^{100} + q_{\text{dchg}} $$ for the positive electrode. For each electrode, $Q^{\max} = Q^{\min} + Q^{\text{cell}}$, where $Q^{\text{cell}}$ is the theoretical capacity of the cell. #### Stoichiometry Relationships In terms of stoichiometries: $$ \theta_n^{\text{elec}}(t) = \theta_n^{100} - \frac{q_{\text{chg}}}{Q_n^{\text{tot}}} $$ $$ \theta_p^{\text{elec}}(t) = \theta_p^{100} + \frac{q_{\text{chg}}}{Q_p^{\text{tot}}} $$ The cell's state of charge is: $$ z = \frac{q_{\text{chg}}}{Q_{\text{cell}}} $$ | Variable | Meaning | | ----------------- | ------------------------------------------ | | $Q^{\text{cell}}$ | Usable capacity of the cell | | $Q^{\min}$ | Capacity at the lower voltage cut-off | | $Q^{\max}$ | Capacity at the upper voltage cut-off | | $\theta^{\min}$ | Stoichiometry at the lower voltage cut-off | | $\theta^{\max}$ | Stoichiometry at the upper voltage cut-off | ## Naming Standards Always use "negative" and "positive" to refer to the electrodes, instead of "anode" and "cathode". Use "lithiation" and "delithiation" to refer to individual electrodes. Reserve "charge" and "discharge" for the full cell. During a whole-cell **discharge**, the negative electrode delithiates and the positive electrode lithiates. During a whole-cell **charge**, the negative electrode lithiates and the positive electrode delithiates. # Get Capabilities Source: https://docs.ionworks.com/api-reference/discovery/get-capabilities https://api.ionworks.com/openapi.json get /discovery/capabilities Discover platform domain context and schema references. Returns domain knowledge (battery data hierarchy, key concepts), authentication requirements, and pointers to JSON Schema endpoints. For available API operations, see the OpenAPI spec at /openapi.json. # Get Data Schema Source: https://docs.ionworks.com/api-reference/discovery/get-data-schema https://api.ionworks.com/openapi.json get /discovery/schemas/data Return the schema for the cell data hierarchy. Describes the required and optional fields for each level: cell_specification, cell_instance, cell_measurement, steps, and time_series. Useful for users or agents building upload payloads. # Get Lab Schema Source: https://docs.ionworks.com/api-reference/discovery/get-lab-schema https://api.ionworks.com/openapi.json get /discovery/schemas/lab Return the schema for the lab-view occupancy status. Read-only: ``response_schema`` is the GET ``/projects/{project_id}/lab/status`` response shape. There is no create/update body (occupancy is derived, not stored). # Get Model Schema Source: https://docs.ionworks.com/api-reference/discovery/get-model-schema https://api.ionworks.com/openapi.json get /discovery/schemas/model Return the schema for custom electrochemical models. Includes ``create_schema`` (POST /models body), ``update_schema`` (PATCH /models/{id} body), ``response_schema`` (GET response), and ``add_custom_variable_schema`` (POST /models/{id}/custom-variables body). # Get Optimization Schema Source: https://docs.ionworks.com/api-reference/discovery/get-optimization-schema https://api.ionworks.com/openapi.json get /discovery/schemas/optimization Return the schema for design optimization runs. Optimization requests use a flat ``run`` body (top-level ``project_id``, ``name``, ``cell_spec_id``, ``optimization_template_id``) plus a nested ``config`` object validated by the design-optimization config schema. # Get Parameterized Model Schema Source: https://docs.ionworks.com/api-reference/discovery/get-parameterized-model-schema https://api.ionworks.com/openapi.json get /discovery/schemas/parameterized_model Return the schema for parameterized models. A parameterized model attaches concrete parameter values to a model and a cell_specification. Includes the create body shape, the partial-update body shape (name and description only), and the full response shape. # Get Pipeline Schema Source: https://docs.ionworks.com/api-reference/discovery/get-pipeline-schema https://api.ionworks.com/openapi.json get /discovery/schemas/pipeline Return the schema for parameterization pipelines. Includes the pipeline config body (POST /pipelines), the full pipeline response shape, and the supported element types (data_fit, array_data_fit, calculation, entry, built_in_entry, validation, linear_confidence_interval, sobol_sensitivity). # Get Project Schema Source: https://docs.ionworks.com/api-reference/discovery/get-project-schema https://api.ionworks.com/openapi.json get /discovery/schemas/project Return the schema for projects. Includes ``create_schema`` (POST /projects body), ``update_schema`` (PATCH /projects/{id} body), and ``response_schema`` (GET response). # Get Protocol Schema Source: https://docs.ionworks.com/api-reference/discovery/get-protocol-schema https://api.ionworks.com/openapi.json get /discovery/schemas/protocol Return the Universal Cycler Protocol (UCP) authoring guide. Describes the YAML input format (single-key dicts keyed by step type), the supported step and end-condition forms, runnable example protocols, and a machine-checkable JSON Schema (``input_schema``) for the authoring format. The JSON Schema is generated from the same enums the parser uses and is parity-tested against ``validate_protocol_from_dict``. # Get Pybamm Models Source: https://docs.ionworks.com/api-reference/discovery/get-pybamm-models https://api.ionworks.com/openapi.json get /discovery/pybamm_models List pybamm/ionworks model classes and options. Used by clients to decide whether they can express their model as a config (``client.model.create``) or whether they need to upload a custom ``pybamm.BaseModel`` subclass via ``client.model.upload_custom``. # Get Simple Pipeline Schema Source: https://docs.ionworks.com/api-reference/discovery/get-simple-pipeline-schema https://api.ionworks.com/openapi.json get /discovery/schemas/simple_pipeline Return the schema for SimplePipelines. Includes the create body (POST /simple_pipelines), the partial-update body (PATCH /simple_pipelines/{id}), the response shape, and the supported element types (data_fit, array_data_fit, calculation, entry, validation). Also reports the at-most-one-expensive-element limit. # Get Study Schema Source: https://docs.ionworks.com/api-reference/discovery/get-study-schema https://api.ionworks.com/openapi.json get /discovery/schemas/study Return the schema for studies. Includes ``create_schema`` (POST body), ``update_schema`` (PATCH body), ``response_schema`` (GET response), and ``mapping_endpoints`` listing the assign/remove paths for simulations and measurements (studies live under ``/projects/{project_id}/studies``). # Serialize Ionworks Model Source: https://docs.ionworks.com/api-reference/discovery/serialize-ionworks-model https://api.ionworks.com/openapi.json post /discovery/ionworks_models/serialize Build an ionworks model and return its pybamm ``Serialise`` JSON. Lets callers without the licensed ``ionworkspipeline`` package use ionworks models (``ECM``, ``LumpedSPMR``, ``GITTModel``, ...): the JSON loads with plain ``pybamm`` via ``Serialise.load_custom_model``. Standard pybamm models are already usable directly, so requesting one returns 400. Returned verbatim because the JSON may contain ``Infinity``/``NaN`` literals; Python's ``json`` (and the SDK) parse these fine. # Validate Pybamm Model Config Source: https://docs.ionworks.com/api-reference/discovery/validate-pybamm-model-config https://api.ionworks.com/openapi.json post /discovery/pybamm_models/validate Try to instantiate a pybamm/ionworks model with the given options. Lightweight check that catches bad combinations of model name + options *before* a record is persisted. Builds the model in-process — same code path ``ModelService.get_pybamm_model_from_config`` uses at simulation time — and reports whether construction succeeded. On failure the response carries the underlying exception's message verbatim (typically a pybamm ``OptionError`` / ``ValueError``) so the caller can act on it. The model is built and dropped; nothing is written to the database. # Health Check Source: https://docs.ionworks.com/api-reference/health-check/health-check https://api.ionworks.com/openapi.json get /healthz Health check endpoint, carrying the commit this instance was built from. Notes ----- ``commit`` lets a caller distinguish *which* build answered, not merely that something did. Porter serves old and new pods simultaneously during a rollout, so a bare liveness reply cannot tell a deploy gate whether the build it is about to test is actually live. Empty when unset (local runs, older images). # Cancel Job Source: https://docs.ionworks.com/api-reference/jobs/cancel-job https://api.ionworks.com/openapi.json post /jobs/{job_id}/cancel Cancel a job if it hasn't been completed yet. Returns the updated job on success, 404 if job not found or already terminal. # Get Job Source: https://docs.ionworks.com/api-reference/jobs/get-job https://api.ionworks.com/openapi.json get /jobs/{job_id} Get details about a specific job. Returns the job's current status, result if completed, or error if failed. # Get Job Metadata Source: https://docs.ionworks.com/api-reference/jobs/get-job-metadata https://api.ionworks.com/openapi.json get /jobs/{job_id}/metadata Get the full metadata blob for a job. Returns the JSON contents of the job's ``metadata.json.gz`` blob in the ``job-files`` Supabase Storage bucket. API clients use this to access fields that are too large for the ``result`` column — for example, the ``validation_results`` and ``validation_plot_config`` payloads written by pipeline validation jobs. # Get Job Metadata Summary Source: https://docs.ionworks.com/api-reference/jobs/get-job-metadata-summary https://api.ionworks.com/openapi.json get /jobs/{job_id}/metadata_summary Get the parts of a job's ``metadata`` a UI reads, projected server-side. The blob is stored in the private ``job-files`` bucket rather than on the job row, and no client has storage grants of its own, so this endpoint is how it is read. Only ``JOB_METADATA_CLIENT_KEYS`` come back — the rest is worker-sized and would be discarded. The response is sanitised (``NaN``/``Infinity`` become ``null``) and is therefore always valid JSON. Returns ``{}`` — not 404 — while the blob has not been written yet, so a caller polling a running job sees an empty object rather than an error. ``GET /jobs/{job_id}/metadata`` remains the whole-blob, 404-on-missing form for scripted clients. # Get Job Validation Series Source: https://docs.ionworks.com/api-reference/jobs/get-job-validation-series https://api.ionworks.com/openapi.json get /jobs/{job_id}/series One objective's validation series, windowed and budgeted. The same payload ``/metadata`` carries whole. That route has to stay -- API clients read fields from it -- but a plot asking it for a time series downloads every sample of both solves to draw a few hundred, which is what this exists to stop. # Get Job Validation Series Channels Source: https://docs.ionworks.com/api-reference/jobs/get-job-validation-series-channels https://api.ionworks.com/openapi.json get /jobs/{job_id}/series/channels Objectives and channel names, without the samples. What a client needs to build its plots. The same names can be read off ``/metadata``, but only by downloading every sample of both solves -- megabytes to populate a variable picker. # List Jobs Source: https://docs.ionworks.com/api-reference/jobs/list-jobs https://api.ionworks.com/openapi.json get /jobs List jobs with optional filtering. Returns a paginated list of jobs with the total count. If job_types is provided, it takes precedence over job_type. # Submit Job Source: https://docs.ionworks.com/api-reference/jobs/submit-job https://api.ionworks.com/openapi.json post /jobs Submit a new job for processing. The job will be added to the queue and processed based on priority. # Build a lab status report for a project Source: https://docs.ionworks.com/api-reference/lab/build-a-lab-status-report-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/report Return a point-in-time lab operations report, rendered as Markdown. Covers momentum over the trend window, utilization (whole lab, per site, per program, and against yesterday), channels released in the recent past, channels forecast to free up soon, the planned measurements queued next, plans that should already have started, equipment service — channels and whole cyclers out, calibration falling due, and booked visits — and healthy channels sitting idle. The response carries every section as structured data *and* the whole thing as a Markdown document in ``markdown``, so a caller can convert it to PDF or paste it into a message without re-deriving anything. This endpoint only builds the report; it does not deliver it anywhere. # Get the lab-view occupancy status for a project Source: https://docs.ionworks.com/api-reference/lab/get-the-lab-view-occupancy-status-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/status Return the project's sites, cyclers, and channels with occupancy state. Each channel carries a derived ``free`` / ``occupied`` / ``stale`` state (there is no live telemetry; state comes from linked measurements' ``end_time`` and update recency). Includes per-cycler and project-level occupancy counts. Each occupied/stale channel's measurement carries a ``watched`` flag for the requesting user, so the "My Channels" filter needs no extra round-trip. # List the measurement IDs the current user is watching Source: https://docs.ionworks.com/api-reference/lab/list-the-measurement-ids-the-current-user-is-watching https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/watched Return the IDs of measurements the current user is watching. A flat list of IDs for membership testing on the client. Includes watches on measurements that may since have finished; the Lab wall intersects them with its live channels, so finished watches simply do not surface. # Get Optimization With Job Source: https://docs.ionworks.com/api-reference/optimization/get-optimization-with-job https://api.ionworks.com/openapi.json get /optimize/{id} Get an optimization and its linked job by optimization id or job_id. # Get Optimization With Jobs Source: https://docs.ionworks.com/api-reference/optimization/get-optimization-with-jobs https://api.ionworks.com/openapi.json get /optimize List optimization jobs and optimization records for the project. Filter parameters support Supabase filter operators: - Text fields (name, template_name, cell_name, model_name, created_by_email): use ``ilike.%value%`` for partial match. - Status: use ``eq.completed`` or ``in.(pending,running)`` for multiple values. - Date range: use ``created_at_gt`` and ``created_at_lt`` for between queries. # Cancel Optimization Source: https://docs.ionworks.com/api-reference/optimizations/cancel-optimization https://api.ionworks.com/openapi.json post /optimizations/{id}/cancel Cancel an optimization and return the refreshed resource. # Get Optimization Config Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-config https://api.ionworks.com/openapi.json get /optimizations/{id}/config Return the resolved config blob for an optimization. Read server-side from storage and sanitised (NaN/Infinity -> null) so clients no longer fetch the job params blob from storage directly. # Get Optimization Metadata Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-metadata https://api.ionworks.com/openapi.json get /optimizations/{id}/metadata Return the metadata blob for an optimization (server-side, sanitised). # Get Optimization Resource Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-resource https://api.ionworks.com/openapi.json get /optimizations/{id} Get a single optimization as a job-free resource. Accepts an optimization id (or, transitionally, a job_id) and returns an :class:`OptimizationResource` with flat status and run count. # Get Optimization Resource Statuses Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-resource-statuses https://api.ionworks.com/openapi.json get /optimizations/statuses Return lightweight statuses for the given optimization ids. For list polling: ``ids`` is a comma-separated list. Returns only id + domain status (no config, results, or runs). # Get Optimization Series Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-series https://api.ionworks.com/openapi.json get /optimizations/{id}/series One objective's validation series, windowed and budgeted. The same rows ``/metadata`` carries whole. A plot reading them from there downloads every sample of both solves to draw a few hundred points. # Get Optimization Series Channels Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-series-channels https://api.ionworks.com/openapi.json get /optimizations/{id}/series/channels Objectives and channel names for this optimization's validation series. Scoped to the optimization rather than the job, because the job's id is not something a client can see: the optimization resource does not carry it, and the adapter's `job_id` is the optimization's own id kept for legacy lookups. Resolving it here is the same step ``/metadata`` already takes. # List Optimization Resources Source: https://docs.ionworks.com/api-reference/optimizations/list-optimization-resources https://api.ionworks.com/openapi.json get /optimizations List optimizations as job-free resources for the project. Same filtering as the legacy list endpoint, but each item is an :class:`OptimizationResource` (flat status, no linked job). ``status`` filters still accept the underlying job-status vocabulary. # List Optimization Runs Source: https://docs.ionworks.com/api-reference/optimizations/list-optimization-runs https://api.ionworks.com/openapi.json get /optimizations/{id}/runs List the multistart runs of an optimization in one call. Each run carries status and convergence history (read server-side), so the frontend renders per-run progress without touching jobs. # Resubmit Optimization Source: https://docs.ionworks.com/api-reference/optimizations/resubmit-optimization https://api.ionworks.com/openapi.json post /optimizations/{id}/resubmit Resubmit an optimization's failed job; return the refreshed resource. # Submit Optimization Source: https://docs.ionworks.com/api-reference/optimizations/submit-optimization https://api.ionworks.com/openapi.json post /optimizations Submit a battery design optimization job using JSON input. # Update Optimization Source: https://docs.ionworks.com/api-reference/optimizations/update-optimization https://api.ionworks.com/openapi.json patch /optimizations/{id} Update an optimization's name and/or description. # Current organization usage and limits for the active billing period Source: https://docs.ionworks.com/api-reference/organizations/current-organization-usage-and-limits-for-the-active-billing-period https://api.ionworks.com/openapi.json get /organizations/current/usage Return the org-wide usage for the current calendar month and configured limits. Usage is aggregated across all members of the organization (limits are org-level) for the current calendar-month period and resets on the first of each month. Simulation usage is a single figure; compute usage is broken down by job type with the total in ``compute.usage``. All values are in hours; a ``None`` limit means that usage type is unconstrained. # Delete a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/delete-a-parameterized-model-1 https://api.ionworks.com/openapi.json delete /parameterized_models/{parameterized_model_id} Delete a parameterized model by its ID for a given cell specification. # Get Parameterized Model Parameter Values Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-parameter-values-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/parameter-values Get all parameter values from a parameterized model as JSON. This endpoint returns the complete parameter set from a parameterized model, suitable for use as baseline parameters in DataFit, parameterization, or optimization workflows. Version and citation metadata are excluded. Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch parameters from parameterized_models_repository : ParameterizedModelsRepository Repository for parameterized model data access Returns ------- dict All parameterized model parameter values as JSON (excluding version and citations) # Get Parameterized Model Spatial Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-spatial-variable-names-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/spatial-variable-names Get variables that can be rendered as one-dimensional spatial profiles. # Get Parameterized Model Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-variable-names-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/variable-names Get variable names where domain is empty or current collector. This endpoint returns the names of all variables in the model where the domain is empty or "current collector" (i.e. they are functions of time only). Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch variables from supabase : Client Supabase client for database access Returns ------- list[str] List of variable names with empty or current collector domain # Update a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/update-a-parameterized-model-1 https://api.ionworks.com/openapi.json patch /parameterized_models/{parameterized_model_id} Update a parameterized model's name and/or description. # Cancel a pipeline Source: https://docs.ionworks.com/api-reference/pipeline/cancel-a-pipeline https://api.ionworks.com/openapi.json post /pipelines/{pipeline_id}/cancel Cancel a running pipeline and all its non-terminal elements. # Cancel multiple pipelines Source: https://docs.ionworks.com/api-reference/pipeline/cancel-multiple-pipelines https://api.ionworks.com/openapi.json post /pipelines/cancel Cancel several pipelines in one request. Marks every selected pipeline (and its non-terminal elements) canceled and offloads all running-job terminations to a single background task, avoiding the per-pipeline request loop the frontend used to run. # Create a new pipeline Source: https://docs.ionworks.com/api-reference/pipeline/create-a-new-pipeline https://api.ionworks.com/openapi.json post /pipelines Creates a new pipeline definition with its associated steps. This endpoint defines the sequence of jobs but does not start execution immediately. Use the 'start' endpoint to begin the pipeline execution. # Get a pipeline element's job_config Source: https://docs.ionworks.com/api-reference/pipeline/get-a-pipeline-elements-job_config https://api.ionworks.com/openapi.json get /pipelines/elements/{element_id}/job_config Return one element's ``job_config``, hydrated lazily on expand. The bulk `/elements` response projects the bigger keys (`parameters`, `optimizer`, `cost`) out of every element so the details page loads fast; this fills them back in for the one element a drawer is showing. Returns ``{}`` for an element that does not exist or is not visible to the caller. Note this is the DB row, not the storage copy — the two differ only by `objectives`, the multi-MB serialized measurement payload the worker reads directly and no client needs. # Get Datafit Plot Data Source: https://docs.ionworks.com/api-reference/pipeline/get-datafit-plot-data https://api.ionworks.com/openapi.json get /pipelines/datafits/{job_id}/plot_data Return decimated trace data for a single data-fit validation plot. Same behaviour as ``GET /validations/{job_id}/plot_data`` but accepts a data-fit job ID. Both job types store ``validation_plot_config`` in the same metadata blob format. # Get pipeline metadata Source: https://docs.ionworks.com/api-reference/pipeline/get-pipeline-metadata https://api.ionworks.com/openapi.json get /pipelines/{pipeline_id} Retrieves pipeline metadata only (no elements). For elements, use GET /pipelines/{pipeline_id}/elements. # Get pipeline result Source: https://docs.ionworks.com/api-reference/pipeline/get-pipeline-result https://api.ionworks.com/openapi.json get /pipelines/{pipeline_id}/result Retrieves the details of a specific pipeline, including its steps and their status. # Get Validation Plot Data Source: https://docs.ionworks.com/api-reference/pipeline/get-validation-plot-data https://api.ionworks.com/openapi.json get /pipelines/validations/{job_id}/plot_data Return decimated trace data for a single validation or data-fit plot. Reads the stored ``validation_plot_config`` from the job's metadata blob, extracts the requested plot, filters to the given x-range, and downsamples to at most ``max_points`` per trace. Supports semantic zoom: callers refetch with ``x_min``/``x_max`` set to the current viewport on each zoom event. ``max_points`` is a per-trace ceiling, and a figure may hold several traces, so it is reduced where the whole response would otherwise exceed the payload cap — see ``src.jobs.plot_data._payload_capped_budget``. # List user's pipelines Source: https://docs.ionworks.com/api-reference/pipeline/list-users-pipelines https://api.ionworks.com/openapi.json get /pipelines Retrieves a list of pipelines for the current user, optionally filtered by project and other criteria. Text fields (name, description, created_by_email) take ``ilike.%value%``, status an exact value or ``in.(a,b)``, date fields ``gte.value`` / ``lte.value``, and created_at_gt/_lt (or updated_at_gt/_lt) bound a range. ``id`` stays alongside ``q``: it is the exact-UUID contract API clients rely on, while ``q`` matches a UUID only as one OR-term. # Apply selected auto-schedule assignments Source: https://docs.ionworks.com/api-reference/planned-measurements/apply-selected-auto-schedule-assignments https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements/apply_auto_schedule_proposal Turn reviewed proposal assignments into scheduled planned measurements. # Create a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/create-a-planned-measurement https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements Create a requested or scheduled planned measurement. # Delete a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/delete-a-planned-measurement https://api.ionworks.com/openapi.json delete /projects/{project_id}/planned_measurements/{planned_measurement_id} Delete a planned measurement. Not-found is idempotent. # Earliest free start time on a channel Source: https://docs.ionworks.com/api-reference/planned-measurements/earliest-free-start-time-on-a-channel https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements/next_available Return the earliest start time a channel is free for ``duration_seconds``. ``next_available_start`` is ``null`` when the channel is occupied by a running measurement with no known end time. # Estimate a planned measurement's duration from its protocol Source: https://docs.ionworks.com/api-reference/planned-measurements/estimate-a-planned-measurements-duration-from-its-protocol https://api.ionworks.com/openapi.json patch /projects/{project_id}/planned_measurements/{planned_measurement_id}/estimate_duration Work out how long this planned test will take, before it runs. Simulates the whole protocol with the cell specification's default model and writes the total into ``estimated_duration_seconds`` -- the value scheduling books channel windows off, which until now was always typed in by hand. ``buffer_pct`` pads the simulated figure by that percentage before booking it, defaulting to ``DEFAULT_BUFFER_PCT``. A protocol whose duration is fixed by the protocol alone is never padded. Returns immediately with ``estimated_duration_status`` set to ``estimating``; the job worker fills the duration in when the simulation finishes. Poll the plan, or watch the status. When the simulation was already run for an identical protocol and model, the estimate comes back ``ready`` instead. For a test that has already started, use the cell measurement's ``estimate_end_time`` endpoint instead: that one replays the measured steps and forecasts only what remains. # Get a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/get-a-planned-measurement https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements/{planned_measurement_id} Get a planned measurement by id within a project. # List planned measurements for a project Source: https://docs.ionworks.com/api-reference/planned-measurements/list-planned-measurements-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements List a project's planned measurements. ``name`` supports Supabase filter operators (e.g. ``ilike.%formation%`` for a partial match, ``eq.Formation`` for an exact match). ``channel_id`` and ``protocol_id`` are exact match. ``q`` is the search box's free-text query, which also matches the related names the table shows; it composes with the column filters above. # Propose reservations for selected requested tests Source: https://docs.ionworks.com/api-reference/planned-measurements/propose-reservations-for-selected-requested-tests https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements/auto_schedule_proposal Return a transient earliest-gap schedule for the selected test requests. # Update a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/update-a-planned-measurement https://api.ionworks.com/openapi.json patch /projects/{project_id}/planned_measurements/{planned_measurement_id} Patch a planned measurement. # Create a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/create-a-project-webhook https://api.ionworks.com/openapi.json post /projects/{project_id}/webhooks Create a webhook; returns the signing secret once. # Delete a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/delete-a-project-webhook https://api.ionworks.com/openapi.json delete /projects/{project_id}/webhooks/{webhook_id} Delete a webhook and cascade its delivery history. # Enqueue a test webhook delivery Source: https://docs.ionworks.com/api-reference/project-webhooks/enqueue-a-test-webhook-delivery https://api.ionworks.com/openapi.json post /projects/{project_id}/webhooks/{webhook_id}/test Enqueue a signed test ping to the webhook URL. # List recent deliveries for a webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/list-recent-deliveries-for-a-webhook https://api.ionworks.com/openapi.json get /projects/{project_id}/webhooks/{webhook_id}/deliveries Return recent delivery history for a webhook. # List webhooks for a project Source: https://docs.ionworks.com/api-reference/project-webhooks/list-webhooks-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/webhooks List project webhooks (signing secrets omitted). # Update a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/update-a-project-webhook https://api.ionworks.com/openapi.json patch /projects/{project_id}/webhooks/{webhook_id} Partially update URL, events, or enabled flag. # Check Protocol Name Endpoint Source: https://docs.ionworks.com/api-reference/protocols/check-protocol-name-endpoint https://api.ionworks.com/openapi.json post /protocols/check_name Check whether a protocol's name describes what the protocol does. Advisory only. A 'mismatch' never blocks saving a protocol — the name is the user's to choose, and a model that misreads a naming convention must not be able to stop work. Returns 'uncheckable' for a name that makes no claim (a codename like "ETR874", a generic "Test", or a name carried in from an uploaded vendor file), so callers can render nothing at all in those cases. Parameters ---------- body : CheckProtocolNameRequest The name, the protocol it names, and whether the name came from a file. Returns ------- CheckProtocolNameResponse The verdict, its confidence, the specific contradictions, and a suggested name. # Compare Protocols Endpoint Source: https://docs.ionworks.com/api-reference/protocols/compare-protocols-endpoint https://api.ionworks.com/openapi.json post /protocols/compare Compare two UCPs and report the differences that change what a cycler does. Cosmetic respellings are not differences: regenerated names, number formatting, redundant nesting, an explicit goto where fall-through was implicit, and a dropped trailing ``End`` all leave behaviour unchanged. What is reported is what reaches the cell -- loop counts, setpoints, terminations, gotos, safety bounds, and block-cumulative durations. Pair it with ``/protocols/convert`` to check a round trip: convert a UCP to a vendor format, parse the result back with ``/protocols/parse``, and compare the two UCPs. A difference there is a conversion that lost something. # Convert Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/convert-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/convert Convert a UCP to a vendor-native protocol file. The primary artifact is returned as base64-encoded bytes alongside a suggested filename and media type. Some targets (currently Maccor with drive cycles) emit additional asset files in ``assets``. # Find Input References Endpoint Source: https://docs.ionworks.com/api-reference/protocols/find-input-references-endpoint https://api.ionworks.com/openapi.json post /protocols/input_references Find all input references from a protocol. This endpoint extracts all input references of the form input["name"] from the provided protocol. The protocol can be provided as a dictionary (JSON), YAML string, or Protocol object serialized as dict. Parameters ---------- body : FindInputReferencesRequest Request containing the protocol to analyze Returns ------- list[str] List of input reference names found in the protocol Raises ------ BadRequestError If protocol parsing fails or input references cannot be extracted # Generate Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/generate-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/generate Generate a UCP protocol from a natural-language description. When the description leaves a value undecided that would change what runs on the channel, the response carries ``questions`` and no protocol. Answer them and call again with the full ``clarifications`` history. Any protocol that *is* returned has already passed the same validation as ``POST /protocols/validate`` and contains no unresolved ``input["..."]`` references, so it can be saved as an experiment template directly. Parameters ---------- body : GenerateProtocolRequest The natural-language prompt, any answers already given, and an optional existing protocol to edit rather than replace. Returns ------- GenerateProtocolResponse Either the generated YAML with a suggested name, an explanation, and the assumptions made — or the questions needed to write it. Raises ------ ExternalServiceError If the model could not produce a valid protocol. # Validate Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/validate-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/validate Validate a protocol dictionary. Runs the full backend validation and returns the result plus advisory lint findings. A protocol can be valid and still carry warnings: operator and end-type mistakes no longer reject it, since one imported from a real cycler file may legitimately contain them. They come back in ``warnings`` so the author sees the problem without being blocked. Parameters ---------- body : ValidateProtocolRequest Request containing the protocol dict to validate Returns ------- dict ``{"valid": true, "warnings": [...]}`` on success, or ``{"valid": false, "error": "..."}`` on failure. ``warnings`` is omitted when there are none. # Cancel a running SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/cancel-a-running-simplepipeline https://api.ionworks.com/openapi.json post /simple_pipelines/{simple_pipeline_id}/cancel Cancel a running SimplePipeline. # Create and submit a SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/create-and-submit-a-simplepipeline https://api.ionworks.com/openapi.json post /simple_pipelines Create a new SimplePipeline and submit it for processing. # Delete a SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/delete-a-simplepipeline https://api.ionworks.com/openapi.json delete /simple_pipelines/{simple_pipeline_id} Delete a SimplePipeline and its associated job. # Get SimplePipeline by ID Source: https://docs.ionworks.com/api-reference/simple_pipeline/get-simplepipeline-by-id https://api.ionworks.com/openapi.json get /simple_pipelines/{simple_pipeline_id} Get a SimplePipeline by ID. # List SimplePipelines Source: https://docs.ionworks.com/api-reference/simple_pipeline/list-simplepipelines https://api.ionworks.com/openapi.json get /simple_pipelines List SimplePipelines for a project with pagination, filters, and ordering. # Update SimplePipeline name/description Source: https://docs.ionworks.com/api-reference/simple_pipeline/update-simplepipeline-namedescription https://api.ionworks.com/openapi.json patch /simple_pipelines/{simple_pipeline_id} Partially update a SimplePipeline's name and/or description. # Assign a measurement to a study Source: https://docs.ionworks.com/api-reference/studies/assign-a-measurement-to-a-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies/{study_id}/measurements/{measurement_id} Assign a measurement to a study. # Assign a simulation to a study Source: https://docs.ionworks.com/api-reference/studies/assign-a-simulation-to-a-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies/{study_id}/simulations/{simulation_id} Assign a simulation to a study. Idempotent: returns the existing mapping (200) if already assigned. # Create a new study Source: https://docs.ionworks.com/api-reference/studies/create-a-new-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies Create a new study associated with a specific project. # Delete a study Source: https://docs.ionworks.com/api-reference/studies/delete-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id} Delete a study by its ID. # Get a specific study by ID Source: https://docs.ionworks.com/api-reference/studies/get-a-specific-study-by-id https://api.ionworks.com/openapi.json get /projects/{project_id}/studies/{study_id} Retrieve a specific study by its unique ID. # List measurements assigned to a study Source: https://docs.ionworks.com/api-reference/studies/list-measurements-assigned-to-a-study https://api.ionworks.com/openapi.json get /projects/{project_id}/studies/{study_id}/measurements Retrieve measurements assigned to a study with pagination. # List studies for a project Source: https://docs.ionworks.com/api-reference/studies/list-studies-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/studies Retrieve studies associated with a specific project with pagination. # Remove a measurement from a study Source: https://docs.ionworks.com/api-reference/studies/remove-a-measurement-from-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id}/measurements/{measurement_id} Remove a measurement assignment from a study. # Remove a simulation from a study Source: https://docs.ionworks.com/api-reference/studies/remove-a-simulation-from-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id}/simulations/{simulation_id} Remove a simulation assignment from a study. # Update a study Source: https://docs.ionworks.com/api-reference/studies/update-a-study https://api.ionworks.com/openapi.json patch /projects/{project_id}/studies/{study_id} Update an existing study. # Get Current User Profile Source: https://docs.ionworks.com/api-reference/users/get-current-user-profile https://api.ionworks.com/openapi.json get /users/me # Add a user to a project or update their project role Source: https://docs.ionworks.com/api-reference/add-a-user-to-a-project-or-update-their-project-role https://api.ionworks.com/openapi.json post /projects/{project_id}/members Upsert (insert or update) a user's role for a project. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. The target user must already belong to the caller's organization. # Create a new model within an organization Source: https://docs.ionworks.com/api-reference/create-a-new-model-within-an-organization https://api.ionworks.com/openapi.json post /models Create a new model in the specified organization. # Create a new project within an organization, assigning creator as Project Admin Source: https://docs.ionworks.com/api-reference/create-a-new-project-within-an-organization-assigning-creator-as-project-admin https://api.ionworks.com/openapi.json post /projects # Delete a project from an organization Source: https://docs.ionworks.com/api-reference/delete-a-project-from-an-organization https://api.ionworks.com/openapi.json delete /projects/{project_id} # Get a specific model by ID within an organization Source: https://docs.ionworks.com/api-reference/get-a-specific-model-by-id-within-an-organization https://api.ionworks.com/openapi.json get /models/{model_id} Get a specific model by ID with config. Validates it belongs to the organization or is a system model. # Get a specific project by ID within an organization Source: https://docs.ionworks.com/api-reference/get-a-specific-project-by-id-within-an-organization https://api.ionworks.com/openapi.json get /projects/{project_id} # List models for an organization Source: https://docs.ionworks.com/api-reference/list-models-for-an-organization https://api.ionworks.com/openapi.json get /models List models for an organization with pagination, filtering, and sorting. Filter parameters support Supabase filter operators, per the field's declared policy: - Text fields (name, description, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # List projects for an organization Source: https://docs.ionworks.com/api-reference/list-projects-for-an-organization https://api.ionworks.com/openapi.json get /projects List projects for an organization with pagination, filtering, and sorting. Filter parameters support Supabase filter operators, per the field's declared policy: - Text fields (name, description): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries There is no ``created_by_email`` filter: the projects table records no creator, so there is nothing to match against. # Remove a user from a project Source: https://docs.ionworks.com/api-reference/remove-a-user-from-a-project https://api.ionworks.com/openapi.json delete /projects/{project_id}/members/{user_id} Remove a user from a project. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. # Update a project within an organization Source: https://docs.ionworks.com/api-reference/update-a-project-within-an-organization https://api.ionworks.com/openapi.json patch /projects/{project_id} # Update a user's project role Source: https://docs.ionworks.com/api-reference/update-a-users-project-role https://api.ionworks.com/openapi.json patch /projects/{project_id}/members/{user_id} Update the project role for an existing member. The caller must have ``project:manage_members`` on the project's organization — enforced by RLS on the user-scoped client. # Add a custom variable to an existing model Source: https://docs.ionworks.com/api-reference/add-a-custom-variable-to-an-existing-model https://api.ionworks.com/openapi.json post /models/{model_id}/custom-variables Append a new custom variable to a model's config (append-only). Validates the expression, serializes it to JSON, and stores it. Custom variables cannot be edited or deleted after creation because evaluated values are persisted in simulation result files. # Create a properties-type measurement directly Source: https://docs.ionworks.com/api-reference/cell-instances/create-a-properties-type-measurement-directly https://api.ionworks.com/openapi.json post /cell_instances/{cell_instance_id}/cell_measurements/create Create a properties-type measurement without the upload flow. Use this for manual measurements like thickness, weight, etc. # Delete a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/delete-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json delete /cell_instances/{cell_instance_id} Deletes an existing cell instance identified by its ID, including: - All measurements (cascades to delete measurement steps) - All associated data files in storage bucket for all measurements - The instance itself # Get a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/get-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json get /cell_instances/{cell_instance_id} Retrieves a specific cell instance by its ID. # Initiate a signed URL upload for measurement data Source: https://docs.ionworks.com/api-reference/cell-instances/initiate-a-signed-url-upload-for-measurement-data https://api.ionworks.com/openapi.json post /cell_instances/{cell_instance_id}/cell_measurements/initiate-upload Initiate upload for time_series or file measurement data. Each returned upload target also carries an optional S3 ``multipart`` block, which clients use instead of the signed URL for large files. Its session token is the caller's own JWT, so storage RLS still applies. # List cell measurements for a cell instance Source: https://docs.ionworks.com/api-reference/cell-instances/list-cell-measurements-for-a-cell-instance https://api.ionworks.com/openapi.json get /cell_instances/{cell_instance_id}/cell_measurements List cell measurements for an instance with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, created_by_email): Use ``ilike.%value%`` for partial match - Date fields: Use ``gte.value``, ``lte.value``, etc. - Date range: Use created_at_gt and created_at_lt for between queries # Update a specific cell instance by id Source: https://docs.ionworks.com/api-reference/cell-instances/update-a-specific-cell-instance-by-id https://api.ionworks.com/openapi.json patch /cell_instances/{cell_instance_id} Updates an existing cell instance identified by its ID. Only provided fields will be updated. # Confirm a signed URL upload and finalize the measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/confirm-a-signed-url-upload-and-finalize-the-measurement https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/confirm-upload Confirm a signed URL upload and finalize the measurement. # Confirm extend upload; stitch series and recompute steps Source: https://docs.ionworks.com/api-reference/cell-measurements/confirm-extend-upload;-stitch-series-and-recompute-steps https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/confirm-extend Confirm an extend upload; stitch series and recompute steps. # Delete a specific cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/delete-a-specific-cell-measurement-by-id https://api.ionworks.com/openapi.json delete /cell_measurements/{measurement_id} Deletes a cell measurement identified by its ID, including: - All storage files (steps.parquet, time_series.parquet) - Database record Returns 204 No Content on success or if already deleted. # Download a measurement file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-a-measurement-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/files/{filename} Stream a specific file in a measurement back to the caller. The bytes are proxied through the backend (same-origin) rather than 307-redirecting to the storage signed URL. A cross-origin redirect to ``http://`` storage trips the CSP ``upgrade-insecure-requests`` directive in local dev (and would otherwise require CORS), which breaks the browser-side blob fetch the file gallery does. # Download steps parquet file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-steps-parquet-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps/download Redirect to storage for the steps parquet file. Returns a 307 redirect to a short-lived signed URL. Most HTTP clients follow redirects automatically, so the caller receives the parquet bytes transparently. # Download time series parquet file Source: https://docs.ionworks.com/api-reference/cell-measurements/download-time-series-parquet-file https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/time_series/download Redirect to storage for the time series parquet file. Returns a 307 redirect to a short-lived signed URL. Most HTTP clients follow redirects automatically, so the caller receives the parquet bytes transparently. Replaces the old ``/time_series/signed_url`` endpoint. # Estimate a running measurement's end time from its remaining protocol Source: https://docs.ionworks.com/api-reference/cell-measurements/estimate-a-running-measurements-end-time-from-its-remaining-protocol https://api.ionworks.com/openapi.json patch /cell_measurements/{measurement_id}/estimate_end_time Forecast when this still-running test will finish. Replays the measured steps onto the protocol's state machine, then simulates only the remainder with the cell specification's default model. Returns immediately, marked ``estimating``; ``estimated_end_time`` lands when the simulation finishes. # Export measurement data (time series, steps, files, metadata) as a zip Source: https://docs.ionworks.com/api-reference/cell-measurements/export-measurement-data-time-series-steps-files-metadata-as-a-zip https://api.ionworks.com/openapi.json post /cell_measurements/export # Get a cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/get-a-cell-measurement-by-id https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id} Retrieve a cell measurement by its ID only. # Get all steps for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-all-steps-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps Retrieve ALL steps for a specific cell measurement. Backend fetches all steps internally (paginating as needed with limit 1000). Returns ``{ steps: {...} }``. # Get cycle metrics only for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-cycle-metrics-only-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/cycles Retrieve cycle metrics only (computed from ALL steps). Backend fetches all steps internally to compute cycles. Returns ``{ cycles: {...} }``, budgeted to ``max_points`` rows when one is given. # Get detailed information for a cell measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-detailed-information-for-a-cell-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/detail Retrieves detailed information for a specific cell measurement by measurement ID. **Deprecated**: prefer the individual endpoints GET /steps, GET /cycles, GET /time_series, and GET /steps_and_cycles instead. Supports zoom-based resampling: specify x_min, x_max, and x_column to fetch higher resolution data for a specific x-axis range. # Get steps and cycle metrics in one call Source: https://docs.ionworks.com/api-reference/cell-measurements/get-steps-and-cycle-metrics-in-one-call https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/steps_and_cycles Retrieve all steps and a budgeted cycle series together. Fetches steps once and computes cycles from the same data, avoiding a double fetch when both are needed. Returns ``{ steps: {...}, cycles: {...} }``. ``max_points`` bounds the **cycle** series only. Steps are returned complete whatever it says, and deliberately: the client resolves step-within-cycle filters against this table and builds its hover labels from it, so a decimated steps table would make the same filter select different rows at different plot widths. The cycle series is what gets drawn, and what a long-cycling test can make unbounded. # Get time series data for a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/get-time-series-data-for-a-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/time_series Return time series data from storage. Does not include steps or cycles. Use GET /steps for steps and GET /cycles for cycle metrics. # Initiate signed URL upload to extend a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/initiate-signed-url-upload-to-extend-a-measurement https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/initiate-extend Mint a signed URL for uploading a delta time series extend file. # List cell measurements Source: https://docs.ionworks.com/api-reference/cell-measurements/list-cell-measurements https://api.ionworks.com/openapi.json get /cell_measurements List cell measurements, scoped to a cell specification or a project. Exactly one of ``cell_specification_id`` (all instances of that spec, page size 1000) or ``project_id`` (paginated, parent instance fields joined, page size 25) is required. ``project_id`` also accepts ``instance_name``, ``cell_instance_id`` and ``spec_id``. ``channel_id`` and ``protocol_id`` are exact match; text takes ``ilike.%value%`` and dates ``gte.value`` / ``lte.value``. # List files in a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/list-files-in-a-measurement https://api.ionworks.com/openapi.json get /cell_measurements/{measurement_id}/files Return the list of filenames stored in a file-type measurement. # Stop watching a measurement Source: https://docs.ionworks.com/api-reference/cell-measurements/stop-watching-a-measurement https://api.ionworks.com/openapi.json delete /cell_measurements/{measurement_id}/watch Stop watching a measurement for the current user. Idempotent -- unwatching something not watched returns 204 all the same. # Update a cell measurement by id Source: https://docs.ionworks.com/api-reference/cell-measurements/update-a-cell-measurement-by-id https://api.ionworks.com/openapi.json patch /cell_measurements/{measurement_id} Update an existing cell measurement's metadata. Only metadata fields can be updated (name, protocol, test_setup, notes, properties, channel_id, protocol_id). Time series and steps data cannot be modified. # Watch a measurement (Lab 'My Channels') Source: https://docs.ionworks.com/api-reference/cell-measurements/watch-a-measurement-lab-my-channels https://api.ionworks.com/openapi.json post /cell_measurements/{measurement_id}/watch Start watching a measurement for the current user. Idempotent -- watching an already-watched measurement is a no-op. Watching the live measurement on a channel is how a user adds that channel to their "My Channels" view. # Create a new cell instance for a cell specification Source: https://docs.ionworks.com/api-reference/cell-specifications/create-a-new-cell-instance-for-a-cell-specification https://api.ionworks.com/openapi.json post /cell_specifications/{cell_spec_id}/cell_instances Creates a new cell instance under the specified cell specification (by ID). Returns the created cell instance and sets the Location header. # Create a new cell specification with nested component/material data Source: https://docs.ionworks.com/api-reference/cell-specifications/create-a-new-cell-specification-with-nested-componentmaterial-data https://api.ionworks.com/openapi.json post /cell_specifications Create a new cell specification with nested component and material data. Materials and components are automatically upserted based on uniqueness: - Materials: unique on (organization_id, name, manufacturer) - Components: unique on (organization_id, component_type, material_id, properties) Example request body: ```json { "name": "NCM622/Graphite Coin Cell", "capacity": 0.002, "lower_voltage_cutoff": 2.5, "upper_voltage_cutoff": 4.2, "form_factor": "R2032", "anode": { "properties": {"diameter_mm": 15, "thickness_um": 23}, "material": {"name": "Graphite", "manufacturer": "Customcells"} }, "cathode": { "properties": {"diameter_mm": 14, "thickness_um": 22}, "material": { "name": "NCM622", "definition": {"formula": "LiNi0.6Co0.2Mn0.2O2"} } } } ``` # Delete a cell specification by id for the current organization Source: https://docs.ionworks.com/api-reference/cell-specifications/delete-a-cell-specification-by-id-for-the-current-organization https://api.ionworks.com/openapi.json delete /cell_specifications/{cell_spec_id} Delete a cell specification and all its instances, measurements, and storage files. # Get a cell specification with nested component and material data Source: https://docs.ionworks.com/api-reference/cell-specifications/get-a-cell-specification-with-nested-component-and-material-data https://api.ionworks.com/openapi.json get /cell_specifications/{cell_spec_id} Retrieve a cell specification with nested component and material information. Returns the specification with anode, cathode, electrolyte, separator, and case components, each including their material details. # List cell instances for a cell specification Source: https://docs.ionworks.com/api-reference/cell-specifications/list-cell-instances-for-a-cell-specification https://api.ionworks.com/openapi.json get /cell_specifications/{cell_spec_id}/cell_instances List cell instances for a specification with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, created_by_email): Use ``ilike.%value%`` for partial match - Date fields: Use ``gte.value``, ``lte.value``, etc. - Date range: Use created_at_gt and created_at_lt for between queries # List cell specifications for the current organization Source: https://docs.ionworks.com/api-reference/cell-specifications/list-cell-specifications-for-the-current-organization https://api.ionworks.com/openapi.json get /cell_specifications List cell specifications with pagination and optional filtering. With ``project_id``, only specs linked to that project; without it, every spec in the organization. Material reverse-lookup: ``material_id``/``material_ids`` search all slots, a per-slot ``_material_id`` only that slot, and ``exclude_cell_spec_id`` drops one spec. These take precedence over the text/date filters, which use Supabase operators (``ilike.%value%``, ``gte.value``); ``created_at_gt``/ ``_lt`` bound a range. ``q`` is the search box's free-text param. # Update a cell specification with nested component/material data Source: https://docs.ionworks.com/api-reference/cell-specifications/update-a-cell-specification-with-nested-componentmaterial-data https://api.ionworks.com/openapi.json patch /cell_specifications/{cell_spec_id} Update an existing cell specification with nested component and material data. Materials and components are automatically upserted based on uniqueness. Only provided fields will be updated. Example request body: ```json { "capacity": 0.003, "anode": { "properties": {"diameter_mm": 16, "thickness_um": 25}, "material": {"name": "Graphite", "manufacturer": "NewSupplier"} } } ``` # Delete a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/delete-a-specific-channel-by-id https://api.ionworks.com/openapi.json delete /channels/{channel_id} Delete an existing channel identified by its ID. Deleting a channel nulls ``cell_measurements.channel_id`` on any referencing measurements (they are not deleted). # Get a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/get-a-specific-channel-by-id https://api.ionworks.com/openapi.json get /channels/{channel_id} Retrieve a specific channel by its ID, scoped to the selected org. # List a channel's out-of-service history Source: https://docs.ionworks.com/api-reference/channels/list-a-channels-out-of-service-history https://api.ionworks.com/openapi.json get /channels/{channel_id}/incidents List the channel's outage spans, most recently started first. An open incident (``resolved_at`` null) is the channel's current outage; at most one can exist. Rows with ``is_estimated`` true were reconstructed from the channel's last-modified time when outage history was introduced, so their ``started_at`` is an upper bound rather than a recorded event. # List channels Source: https://docs.ionworks.com/api-reference/channels/list-channels https://api.ionworks.com/openapi.json get /channels List channels with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries ``q`` is the free-text search box's param: it narrows on channel name substring plus a prefix full-text match over ``search_vector``, and composes with the column filters above. # Update a specific channel by id Source: https://docs.ionworks.com/api-reference/channels/update-a-specific-channel-by-id https://api.ionworks.com/openapi.json patch /channels/{channel_id} Update an existing channel identified by its ID. Only provided fields will be updated. Taking a channel out of service (or returning it) also records an outage span; ``user_id`` is threaded through so the incident records who did it. # Create a new channel for a cycler Source: https://docs.ionworks.com/api-reference/cyclers/create-a-new-channel-for-a-cycler https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/channels Create a new channel under the specified cycler (by ID). Returns the created channel and sets the Location header. # Delete a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/delete-a-specific-cycler-by-id https://api.ionworks.com/openapi.json delete /cyclers/{cycler_id} Delete an existing cycler identified by its ID. Foreign-key cascade drops child channels (cyclers -> channels). # Get a cycler's open service event, if any Source: https://docs.ionworks.com/api-reference/cyclers/get-a-cyclers-open-service-event-if-any https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/service_events/open Return the cycler's current service, or ``null`` when it is in service. A dedicated read rather than making the caller scan the history: that list is ordered newest-created first and paginated, so an event recorded after the fact sorts ahead of an older open one and would eventually hide it — the cycler would read as in service while it is out. At most one open event can exist, so this is exact. # Get a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/get-a-specific-cycler-by-id https://api.ionworks.com/openapi.json get /cyclers/{cycler_id} Retrieve a specific cycler by its ID, scoped to the selected org. # List a cycler's service history Source: https://docs.ionworks.com/api-reference/cyclers/list-a-cyclers-service-history https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/service_events List the cycler's service events, most recently created first. An event with a null ``performed_at`` is the cycler's current service; at most one can exist. Completing a ``calibration`` event is what advances the cycler's ``last_calibrated_at`` and therefore its ``calibration_due_at``. # List channels for a cycler Source: https://docs.ionworks.com/api-reference/cyclers/list-channels-for-a-cycler https://api.ionworks.com/openapi.json get /cyclers/{cycler_id}/channels List channels for a cycler with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries ``q`` is the free-text search box's param: it narrows on channel name substring plus a prefix full-text match over ``search_vector``, and composes with the column filters above. # List cyclers Source: https://docs.ionworks.com/api-reference/cyclers/list-cyclers https://api.ionworks.com/openapi.json get /cyclers List cyclers with pagination and filtering. Filters follow each field's policy in ``_CYCLER_FILTERS``: text fields take ``ilike.%value%``, ``site_id``/``project_id`` are exact-only, date fields take ``gte.value``/``lte.value``, ``created_at_gt``/``_lt`` bound a range, and ``calibration_due_at_lt`` finds cyclers due before a date. ``q`` is the search box's free-text param and composes with all of them. # Record a cycler's open service as done Source: https://docs.ionworks.com/api-reference/cyclers/record-a-cyclers-open-service-as-done https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/service_events/complete Record service as done on a cycler. Two paths behind one endpoint, chosen by whether the cycler is currently out of service: - **Open event** (it was taken out first): that event is completed and every channel incident pointing at it is closed in one write, so the cycler cannot end up half returned to service. - **No open event**: the common case of "I calibrated this, log it". A single already-completed event is recorded from the request's ``event_type``. Nothing was taken out, so nothing needs returning, and no one has to open an event purely so it can be closed. Either way a ``calibration`` advances ``last_calibrated_at`` — the only supported way that field moves — and optionally updates ``calibration_interval_days``. Addressed by cycler rather than by event id because a cycler has at most one open event: the caller already knows which cycler it serviced, and looking up the event id first would be a round-trip that proves nothing. # Take a cycler out of service for planned work Source: https://docs.ionworks.com/api-reference/cyclers/take-a-cycler-out-of-service-for-planned-work https://api.ionworks.com/openapi.json post /cyclers/{cycler_id}/service_events Open a service event, taking every channel on the cycler out of service. One call replaces taking each channel out individually: calibration is performed on the instrument, so the whole cycler goes down as one event and comes back as one. Channels already out of service keep their existing incident (a broken relay is more specific than "being serviced"). Rejected with 409 if the cycler already has an open event, or if any channel still has a running measurement. # Update a specific cycler by id Source: https://docs.ionworks.com/api-reference/cyclers/update-a-specific-cycler-by-id https://api.ionworks.com/openapi.json patch /cyclers/{cycler_id} Update an existing cycler identified by its ID. Only provided fields will be updated. # Delete a model from an organization Source: https://docs.ionworks.com/api-reference/delete-a-model-from-an-organization https://api.ionworks.com/openapi.json delete /models/{model_id} Delete a model, validating it belongs to the organization. # Get Capabilities Source: https://docs.ionworks.com/api-reference/discovery/get-capabilities https://api.ionworks.com/openapi.json get /discovery/capabilities Discover platform domain context and schema references. Returns domain knowledge (battery data hierarchy, key concepts), authentication requirements, and pointers to JSON Schema endpoints. For available API operations, see the OpenAPI spec at /openapi.json. # Get Data Schema Source: https://docs.ionworks.com/api-reference/discovery/get-data-schema https://api.ionworks.com/openapi.json get /discovery/schemas/data Return the schema for the cell data hierarchy. Describes the required and optional fields for each level: cell_specification, cell_instance, cell_measurement, steps, and time_series. Useful for users or agents building upload payloads. # Get Lab Schema Source: https://docs.ionworks.com/api-reference/discovery/get-lab-schema https://api.ionworks.com/openapi.json get /discovery/schemas/lab Return the schema for the lab-view occupancy status. Read-only: ``response_schema`` is the GET ``/projects/{project_id}/lab/status`` response shape. There is no create/update body (occupancy is derived, not stored). # Get Model Schema Source: https://docs.ionworks.com/api-reference/discovery/get-model-schema https://api.ionworks.com/openapi.json get /discovery/schemas/model Return the schema for custom electrochemical models. Includes ``create_schema`` (POST /models body), ``update_schema`` (PATCH /models/{id} body), ``response_schema`` (GET response), and ``add_custom_variable_schema`` (POST /models/{id}/custom-variables body). # Get Project Schema Source: https://docs.ionworks.com/api-reference/discovery/get-project-schema https://api.ionworks.com/openapi.json get /discovery/schemas/project Return the schema for projects. Includes ``create_schema`` (POST /projects body), ``update_schema`` (PATCH /projects/{id} body), and ``response_schema`` (GET response). # Get Protocol Schema Source: https://docs.ionworks.com/api-reference/discovery/get-protocol-schema https://api.ionworks.com/openapi.json get /discovery/schemas/protocol Return the Universal Cycler Protocol (UCP) authoring guide. Describes the YAML input format (single-key dicts keyed by step type), the supported step and end-condition forms, runnable example protocols, and a machine-checkable JSON Schema (``input_schema``) for the authoring format. The JSON Schema is generated from the same enums the parser uses and is parity-tested against ``validate_protocol_from_dict``. # Get Pybamm Models Source: https://docs.ionworks.com/api-reference/discovery/get-pybamm-models https://api.ionworks.com/openapi.json get /discovery/pybamm_models List pybamm/ionworks model classes and options. Used by clients to decide whether they can express their model as a config (``client.model.create``) or whether they need to upload a custom ``pybamm.BaseModel`` subclass via ``client.model.upload_custom``. # Get Study Schema Source: https://docs.ionworks.com/api-reference/discovery/get-study-schema https://api.ionworks.com/openapi.json get /discovery/schemas/study Return the schema for studies. Includes ``create_schema`` (POST body), ``update_schema`` (PATCH body), ``response_schema`` (GET response), and ``mapping_endpoints`` listing the assign/remove paths for simulations and measurements (studies live under ``/projects/{project_id}/studies``). # Create a new parameterized model for the current cell specification Source: https://docs.ionworks.com/api-reference/parameterized_models/create-a-new-parameterized-model-for-the-current-cell-specification https://api.ionworks.com/openapi.json post /cells/{cell_spec_id}/parameterized_models # Create a new parameterized model for the current cell specification Source: https://docs.ionworks.com/api-reference/parameterized_models/create-a-new-parameterized-model-for-the-current-cell-specification-1 https://api.ionworks.com/openapi.json post /parameterized_models # Delete a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/delete-a-parameterized-model https://api.ionworks.com/openapi.json delete /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Delete a parameterized model by its ID for a given cell specification. # Delete a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/delete-a-parameterized-model-1 https://api.ionworks.com/openapi.json delete /parameterized_models/{parameterized_model_id} Delete a parameterized model by its ID for a given cell specification. # Get a parameterized model by id Source: https://docs.ionworks.com/api-reference/parameterized_models/get-a-parameterized-model-by-id https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Get a single parameterized model (with its model info) by id. Lets clients fetch one model directly instead of listing every model in a project. Access is scoped by row-level security via the requesting user's client. # Get a parameterized model by id Source: https://docs.ionworks.com/api-reference/parameterized_models/get-a-parameterized-model-by-id-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id} Get a single parameterized model (with its model info) by id. Lets clients fetch one model directly instead of listing every model in a project. Access is scoped by row-level security via the requesting user's client. # Get Parameterized Model Parameter Values Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-parameter-values https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/parameter-values Get all parameter values from a parameterized model as JSON. This endpoint returns the complete parameter set from a parameterized model, suitable for use as baseline parameters in DataFit, parameterization, or optimization workflows. Version and citation metadata are excluded. Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch parameters from parameterized_models_repository : ParameterizedModelsRepository Repository for parameterized model data access Returns ------- dict All parameterized model parameter values as JSON (excluding version and citations) # Get Parameterized Model Parameter Values Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-parameter-values-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/parameter-values Get all parameter values from a parameterized model as JSON. This endpoint returns the complete parameter set from a parameterized model, suitable for use as baseline parameters in DataFit, parameterization, or optimization workflows. Version and citation metadata are excluded. Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch parameters from parameterized_models_repository : ParameterizedModelsRepository Repository for parameterized model data access Returns ------- dict All parameterized model parameter values as JSON (excluding version and citations) # Get Parameterized Model Spatial Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-spatial-variable-names https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/spatial-variable-names Get variables that can be rendered as one-dimensional spatial profiles. # Get Parameterized Model Spatial Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-spatial-variable-names-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/spatial-variable-names Get variables that can be rendered as one-dimensional spatial profiles. # Get Parameterized Model Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-variable-names https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id}/variable-names Get variable names where domain is empty or current collector. This endpoint returns the names of all variables in the model where the domain is empty or "current collector" (i.e. they are functions of time only). Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch variables from supabase : Client Supabase client for database access Returns ------- list[str] List of variable names with empty or current collector domain # Get Parameterized Model Variable Names Source: https://docs.ionworks.com/api-reference/parameterized_models/get-parameterized-model-variable-names-1 https://api.ionworks.com/openapi.json get /parameterized_models/{parameterized_model_id}/variable-names Get variable names where domain is empty or current collector. This endpoint returns the names of all variables in the model where the domain is empty or "current collector" (i.e. they are functions of time only). Parameters ---------- parameterized_model_id : str ID of the parameterized model to fetch variables from supabase : Client Supabase client for database access Returns ------- list[str] List of variable names with empty or current collector domain # List parameterized models for a cell specification or project Source: https://docs.ionworks.com/api-reference/parameterized_models/list-parameterized-models-for-a-cell-specification-or-project https://api.ionworks.com/openapi.json get /cells/{cell_spec_id}/parameterized_models List parameterized models with pagination, filtering, and sorting, scoped by cell spec or project. Provide exactly one scope: - ``cell_spec_id`` lists models for a single cell specification. This is the path parameter when called via ``/cells/{cell_spec_id}/parameterized_models``. - ``project_id`` lists models across every cell specification linked to the project. ``cell_spec_id`` may additionally be passed to narrow a project-scoped query to one cell specification. Parameters ---------- model_id : str | None Base model to narrow to, matched exactly. Composes with either scope — e.g. every parameterized model in a project that derives from one model. cell_spec_id : str | None Cell specification to scope to. Required when ``project_id`` is not given. project_id : str | None Project to scope to. When set, ``cell_spec_id`` is an optional further filter rather than the primary scope. name : str | None Column filter on the model name. Applies to both scopes. Values use Supabase operator syntax and are validated by the field policy — e.g. ``ilike.%alpha%`` for a case-insensitive substring match. A bare value falls through to an exact match. description : str | None Column filter on the model description. Same operator-syntax semantics as ``name`` and likewise applies to both scopes. q : str | None Free-text search on the model name plus a prefix full-text match, matching the global search. Applies to both scopes and composes with the ``name`` / ``description`` column filters. order_by : {"name", "created_at"} Column to sort the cell-spec-scoped results by. Defaults to ``"created_at"``. order : {"asc", "desc"} Sort direction for the cell-spec-scoped results. Defaults to ``"desc"``. limit : int Maximum number of models to return per page (1-1000). Defaults to 100. The upper bound is 1000 so callers (e.g. the optimization clone form) can load every model for a project in one request. offset : int Number of models to skip for pagination. Defaults to 0. # List parameterized models for a cell specification or project Source: https://docs.ionworks.com/api-reference/parameterized_models/list-parameterized-models-for-a-cell-specification-or-project-1 https://api.ionworks.com/openapi.json get /parameterized_models List parameterized models with pagination, filtering, and sorting, scoped by cell spec or project. Provide exactly one scope: - ``cell_spec_id`` lists models for a single cell specification. This is the path parameter when called via ``/cells/{cell_spec_id}/parameterized_models``. - ``project_id`` lists models across every cell specification linked to the project. ``cell_spec_id`` may additionally be passed to narrow a project-scoped query to one cell specification. Parameters ---------- model_id : str | None Base model to narrow to, matched exactly. Composes with either scope — e.g. every parameterized model in a project that derives from one model. cell_spec_id : str | None Cell specification to scope to. Required when ``project_id`` is not given. project_id : str | None Project to scope to. When set, ``cell_spec_id`` is an optional further filter rather than the primary scope. name : str | None Column filter on the model name. Applies to both scopes. Values use Supabase operator syntax and are validated by the field policy — e.g. ``ilike.%alpha%`` for a case-insensitive substring match. A bare value falls through to an exact match. description : str | None Column filter on the model description. Same operator-syntax semantics as ``name`` and likewise applies to both scopes. q : str | None Free-text search on the model name plus a prefix full-text match, matching the global search. Applies to both scopes and composes with the ``name`` / ``description`` column filters. order_by : {"name", "created_at"} Column to sort the cell-spec-scoped results by. Defaults to ``"created_at"``. order : {"asc", "desc"} Sort direction for the cell-spec-scoped results. Defaults to ``"desc"``. limit : int Maximum number of models to return per page (1-1000). Defaults to 100. The upper bound is 1000 so callers (e.g. the optimization clone form) can load every model for a project in one request. offset : int Number of models to skip for pagination. Defaults to 0. # Update a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/update-a-parameterized-model https://api.ionworks.com/openapi.json patch /cells/{cell_spec_id}/parameterized_models/{parameterized_model_id} Update a parameterized model's name and/or description. # Update a parameterized model Source: https://docs.ionworks.com/api-reference/parameterized_models/update-a-parameterized-model-1 https://api.ionworks.com/openapi.json patch /parameterized_models/{parameterized_model_id} Update a parameterized model's name and/or description. # Create a new program Source: https://docs.ionworks.com/api-reference/programs/create-a-new-program https://api.ionworks.com/openapi.json post /programs Create a new lab test program for the current organization. Returns the created program and sets the Location header. # Delete a specific program by id Source: https://docs.ionworks.com/api-reference/programs/delete-a-specific-program-by-id https://api.ionworks.com/openapi.json delete /programs/{program_id} Delete an existing lab test program identified by its ID. Planned measurements and cell measurements referencing this program have their ``program_id`` set to null (``ON DELETE SET NULL``); they are not otherwise affected. # Get a specific program by id Source: https://docs.ionworks.com/api-reference/programs/get-a-specific-program-by-id https://api.ionworks.com/openapi.json get /programs/{program_id} Retrieve a specific lab test program by its ID, scoped to the org. # List programs Source: https://docs.ionworks.com/api-reference/programs/list-programs https://api.ionworks.com/openapi.json get /programs List lab test programs for the current organization, with pagination. # Update a specific program by id Source: https://docs.ionworks.com/api-reference/programs/update-a-specific-program-by-id https://api.ionworks.com/openapi.json patch /programs/{program_id} Update an existing lab test program identified by its ID. Only provided fields will be updated. # Create Simulation Batch With Template Source: https://docs.ionworks.com/api-reference/simulations/create-simulation-batch-with-template https://api.ionworks.com/openapi.json post /simulations/with-template/batch Optimized batch simulation creation with template-based experiments. This endpoint is for creating multiple simulations using template-based experiments with DOE support. For protocol-based batch simulations, use POST /simulations/batch. Returns ------- List[SimulationCreationResponse] List of simulation creation responses with IDs, job info, and status Raises ------ HTTPException If usage limit reached or job submission fails # Get Simulation Source: https://docs.ionworks.com/api-reference/simulations/get-simulation https://api.ionworks.com/openapi.json get /simulations/{simulation_id} # Get Simulation Cycles Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-cycles https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/cycles Get cycle-level metrics for a simulation. Computes metrics like discharge capacity, coulombic efficiency, capacity retention, etc. for each cycle from the steps.parquet file using ionworksdata.cycle_metrics.get_cycle_metrics. Parameters ---------- simulation_id : str ID of the simulation user_org_client : tuple[str, str | None, AsyncClient] Current user_id, organization_id, and Supabase client simulation_repository : SimulationRepository Repository for simulations table simulation_data_repository : SimulationDataRepository Repository for simulation_data table Returns ------- dict Dictionary with cycle metrics as lists, one value per cycle. Keys include: Cycle number, Discharge capacity [A.h], Charge capacity [A.h], Coulombic efficiency, Capacity retention, etc. Raises ------ HTTPException 404 if no results found, 500 for computation errors # Get Simulation Result Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-result https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/result Get stored results for a simulation. Handles multiple data formats: 1. Parquet files in storage (newest format) - loaded from storage 2. JSONB in simulation_data table (legacy) - migrated to parquet Parameters ---------- simulation_id : str ID of the simulation user_org_client : tuple[str, str | None, AsyncClient] Current user_id, organization_id, and Supabase client simulation_repository : SimulationRepository Repository for simulations table simulation_data_repository : SimulationDataRepository Repository for simulation_data table (legacy) target_rows : int | None If set, downsample time series to at most this many rows. x_min : float | None Minimum Time [s] value to filter (for zoom). x_max : float | None Maximum Time [s] value to filter (for zoom). Returns ------- dict Dictionary with time_series, steps, and metrics Raises ------ HTTPException 404 if no results found, 400 for other errors # Get Simulation Spatial Profile Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-spatial-profile https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/spatial-profile Evaluate a one-dimensional spatial field from stored solution chunks. # Get Simulation Summary Source: https://docs.ionworks.com/api-reference/simulations/get-simulation-summary https://api.ionworks.com/openapi.json get /simulations/{simulation_id}/summary Fetch a simulation summary without simulation_data. Suitable for list view refresh. # List Simulations Source: https://docs.ionworks.com/api-reference/simulations/list-simulations https://api.ionworks.com/openapi.json get /simulations List simulations filtered by a parameterized model, study, or protocol. Exactly one of ``parameterized_model_id``, ``study_id``, or ``experiment_template_id`` must be provided. Returns simulation summaries without simulation_data, plus a map of model scalar parameters deduplicated by parameterized_model_id. Parameters ---------- parameterized_model_id : str, optional Filter simulations belonging to this parameterized model. study_id : str, optional Filter simulations assigned to this study. experiment_template_id : str, optional Filter simulations whose experiment references this protocol template. limit : int, optional Maximum number of results to return. Must be between 1 and 1000. Defaults to 100. offset : int, optional Number of results to skip for pagination. Must be >= 0. Defaults to 0. status : str, optional PostgREST filter expression on the simulation's job status, e.g. ``"eq.completed"`` or ``"in.(pending,processing,waiting)"``. When provided, only simulations whose job matches are returned. model_name, protocol_name : str, optional Exact-match expressions (e.g. ``"eq.My model"``) on the joined parameterized model name and protocol (experiment template) name. A simulation has no name of its own, so these two are what identify it. created_at_gt, created_at_lt, updated_at_gt, updated_at_lt : str, optional ISO datetime strings for date-range (between) filtering on the native ``created_at`` / ``updated_at`` columns. Returns ------- ListSimulationsResponse Simulations, model scalar parameters map, and total count. # Create a new cycler for a site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-cycler-for-a-site https://api.ionworks.com/openapi.json post /sites/{site_id}/cyclers Create a new cycler under the specified site (by ID). The request body must include ``project_id`` — the project that will own this cycler. The site is org-scoped (cross-project), so any of the org's sites may host a cycler for any of its projects. Returns the created cycler and sets the Location header. # Create a new site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-site https://api.ionworks.com/openapi.json post /sites Create a new site for the current organization. Returns the created site and sets the Location header. # Create a new thermal chamber for a site Source: https://docs.ionworks.com/api-reference/sites/create-a-new-thermal-chamber-for-a-site https://api.ionworks.com/openapi.json post /sites/{site_id}/thermal_chambers Create a new thermal chamber under the specified site (by ID). The request body must include ``project_id`` — the project that will own this chamber — and both temperature bounds. The site is org-scoped (cross-project), so any of the org's sites may host a chamber for any of its projects. Returns the created chamber and sets the Location header. # Delete a specific site by id Source: https://docs.ionworks.com/api-reference/sites/delete-a-specific-site-by-id https://api.ionworks.com/openapi.json delete /sites/{site_id} Delete an existing site identified by its ID. Foreign-key cascade drops child cyclers and their channels (sites -> cyclers -> channels). # Get a specific site by id Source: https://docs.ionworks.com/api-reference/sites/get-a-specific-site-by-id https://api.ionworks.com/openapi.json get /sites/{site_id} Retrieve a specific site by its ID, scoped to the selected org. # List cyclers for a site Source: https://docs.ionworks.com/api-reference/sites/list-cyclers-for-a-site https://api.ionworks.com/openapi.json get /sites/{site_id}/cyclers List cyclers for a site with pagination and filtering. Filters follow each field's policy in ``_CYCLER_FILTERS``: text fields take ``ilike.%value%``, ``project_id`` is exact-only, date fields take ``gte.value``/``lte.value``, ``created_at_gt``/``_lt`` bound a range, and ``calibration_due_at_lt`` finds cyclers due before a date. ``q`` is the search box's free-text param and composes with all of them. # List sites Source: https://docs.ionworks.com/api-reference/sites/list-sites https://api.ionworks.com/openapi.json get /sites List sites with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, location, created_by_email): use ``ilike.%value%`` for partial match - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # List thermal chambers for a site Source: https://docs.ionworks.com/api-reference/sites/list-thermal-chambers-for-a-site https://api.ionworks.com/openapi.json get /sites/{site_id}/thermal_chambers List thermal chambers for a site with pagination and filtering. Filter parameters support Supabase filter operators, per the field's declared policy in ``_THERMAL_CHAMBER_FILTERS``: - Text fields (name, id_text, manufacturer, model, serial_number, created_by_email): use ``ilike.%value%`` for partial match - Exact fields (project_id): literal equality; operator syntax is rejected - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # Update a specific site by id Source: https://docs.ionworks.com/api-reference/sites/update-a-specific-site-by-id https://api.ionworks.com/openapi.json patch /sites/{site_id} Update an existing site identified by its ID. Only provided fields will be updated. # Delete a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/delete-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json delete /thermal_chambers/{thermal_chamber_id} Delete a thermal chamber by its ID. Nothing references a thermal chamber yet, so the delete is unconditional — there is no child row to cascade and no measurement history to strand. # Get a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/get-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json get /thermal_chambers/{thermal_chamber_id} Retrieve a specific thermal chamber by its ID, scoped to the selected org. # List thermal chambers Source: https://docs.ionworks.com/api-reference/thermal-chambers/list-thermal-chambers https://api.ionworks.com/openapi.json get /thermal_chambers List thermal chambers with pagination and filtering. Filter parameters support Supabase filter operators: - Text fields (name, id_text, manufacturer, model, serial_number, created_by_email): use ``ilike.%value%`` for partial match - Exact-match fields (site_id, project_id): equality filter - Date fields: use ``gte.value``, ``lte.value``, etc. - Date range: use created_at_gt and created_at_lt for between queries # Update a specific thermal chamber by id Source: https://docs.ionworks.com/api-reference/thermal-chambers/update-a-specific-thermal-chamber-by-id https://api.ionworks.com/openapi.json patch /thermal_chambers/{thermal_chamber_id} Update an existing thermal chamber identified by its ID. Only provided fields will be updated. The service validates the *merged* temperature bounds against the stored row, so a PATCH sending only one bound cannot leave the chamber with an inverted range. # Update a model within an organization Source: https://docs.ionworks.com/api-reference/update-a-model-within-an-organization https://api.ionworks.com/openapi.json patch /models/{model_id} Update a model, validating it belongs to the organization. # Get Optimization Schema Source: https://docs.ionworks.com/api-reference/discovery/get-optimization-schema https://api.ionworks.com/openapi.json get /discovery/schemas/optimization Return the schema for design optimization runs. Optimization requests use a flat ``run`` body (top-level ``project_id``, ``name``, ``cell_spec_id``, ``optimization_template_id``) plus a nested ``config`` object validated by the design-optimization config schema. # Get Parameterized Model Schema Source: https://docs.ionworks.com/api-reference/discovery/get-parameterized-model-schema https://api.ionworks.com/openapi.json get /discovery/schemas/parameterized_model Return the schema for parameterized models. A parameterized model attaches concrete parameter values to a model and a cell_specification. Includes the create body shape, the partial-update body shape (name and description only), and the full response shape. # Get Pipeline Schema Source: https://docs.ionworks.com/api-reference/discovery/get-pipeline-schema https://api.ionworks.com/openapi.json get /discovery/schemas/pipeline Return the schema for parameterization pipelines. Includes the pipeline config body (POST /pipelines), the full pipeline response shape, and the supported element types (data_fit, array_data_fit, calculation, entry, built_in_entry, validation, linear_confidence_interval, sobol_sensitivity). # Get Simple Pipeline Schema Source: https://docs.ionworks.com/api-reference/discovery/get-simple-pipeline-schema https://api.ionworks.com/openapi.json get /discovery/schemas/simple_pipeline Return the schema for SimplePipelines. Includes the create body (POST /simple_pipelines), the partial-update body (PATCH /simple_pipelines/{id}), the response shape, and the supported element types (data_fit, array_data_fit, calculation, entry, validation). Also reports the at-most-one-expensive-element limit. # Serialize Ionworks Model Source: https://docs.ionworks.com/api-reference/discovery/serialize-ionworks-model https://api.ionworks.com/openapi.json post /discovery/ionworks_models/serialize Build an ionworks model and return its pybamm ``Serialise`` JSON. Lets callers without the licensed ``ionworkspipeline`` package use ionworks models (``ECM``, ``LumpedSPMR``, ``GITTModel``, ...): the JSON loads with plain ``pybamm`` via ``Serialise.load_custom_model``. Standard pybamm models are already usable directly, so requesting one returns 400. Returned verbatim because the JSON may contain ``Infinity``/``NaN`` literals; Python's ``json`` (and the SDK) parse these fine. # Validate Pybamm Model Config Source: https://docs.ionworks.com/api-reference/discovery/validate-pybamm-model-config https://api.ionworks.com/openapi.json post /discovery/pybamm_models/validate Try to instantiate a pybamm/ionworks model with the given options. Lightweight check that catches bad combinations of model name + options *before* a record is persisted. Builds the model in-process — same code path ``ModelService.get_pybamm_model_from_config`` uses at simulation time — and reports whether construction succeeded. On failure the response carries the underlying exception's message verbatim (typically a pybamm ``OptionError`` / ``ValueError``) so the caller can act on it. The model is built and dropped; nothing is written to the database. # Health Check Source: https://docs.ionworks.com/api-reference/health-check/health-check https://api.ionworks.com/openapi.json get /healthz Health check endpoint, carrying the commit this instance was built from. Notes ----- ``commit`` lets a caller distinguish *which* build answered, not merely that something did. Porter serves old and new pods simultaneously during a rollout, so a bare liveness reply cannot tell a deploy gate whether the build it is about to test is actually live. Empty when unset (local runs, older images). # Cancel Job Source: https://docs.ionworks.com/api-reference/jobs/cancel-job https://api.ionworks.com/openapi.json post /jobs/{job_id}/cancel Cancel a job if it hasn't been completed yet. Returns the updated job on success, 404 if job not found or already terminal. # Get Job Source: https://docs.ionworks.com/api-reference/jobs/get-job https://api.ionworks.com/openapi.json get /jobs/{job_id} Get details about a specific job. Returns the job's current status, result if completed, or error if failed. # Get Job Metadata Source: https://docs.ionworks.com/api-reference/jobs/get-job-metadata https://api.ionworks.com/openapi.json get /jobs/{job_id}/metadata Get the full metadata blob for a job. Returns the JSON contents of the job's ``metadata.json.gz`` blob in the ``job-files`` Supabase Storage bucket. API clients use this to access fields that are too large for the ``result`` column — for example, the ``validation_results`` and ``validation_plot_config`` payloads written by pipeline validation jobs. # Get Job Metadata Summary Source: https://docs.ionworks.com/api-reference/jobs/get-job-metadata-summary https://api.ionworks.com/openapi.json get /jobs/{job_id}/metadata_summary Get the parts of a job's ``metadata`` a UI reads, projected server-side. The blob is stored in the private ``job-files`` bucket rather than on the job row, and no client has storage grants of its own, so this endpoint is how it is read. Only ``JOB_METADATA_CLIENT_KEYS`` come back — the rest is worker-sized and would be discarded. The response is sanitised (``NaN``/``Infinity`` become ``null``) and is therefore always valid JSON. Returns ``{}`` — not 404 — while the blob has not been written yet, so a caller polling a running job sees an empty object rather than an error. ``GET /jobs/{job_id}/metadata`` remains the whole-blob, 404-on-missing form for scripted clients. # Get Job Validation Series Source: https://docs.ionworks.com/api-reference/jobs/get-job-validation-series https://api.ionworks.com/openapi.json get /jobs/{job_id}/series One objective's validation series, windowed and budgeted. The same payload ``/metadata`` carries whole. That route has to stay -- API clients read fields from it -- but a plot asking it for a time series downloads every sample of both solves to draw a few hundred, which is what this exists to stop. # Get Job Validation Series Channels Source: https://docs.ionworks.com/api-reference/jobs/get-job-validation-series-channels https://api.ionworks.com/openapi.json get /jobs/{job_id}/series/channels Objectives and channel names, without the samples. What a client needs to build its plots. The same names can be read off ``/metadata``, but only by downloading every sample of both solves -- megabytes to populate a variable picker. # List Jobs Source: https://docs.ionworks.com/api-reference/jobs/list-jobs https://api.ionworks.com/openapi.json get /jobs List jobs with optional filtering. Returns a paginated list of jobs with the total count. If job_types is provided, it takes precedence over job_type. # Submit Job Source: https://docs.ionworks.com/api-reference/jobs/submit-job https://api.ionworks.com/openapi.json post /jobs Submit a new job for processing. The job will be added to the queue and processed based on priority. # Build a lab status report for a project Source: https://docs.ionworks.com/api-reference/lab/build-a-lab-status-report-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/report Return a point-in-time lab operations report, rendered as Markdown. Covers momentum over the trend window, utilization (whole lab, per site, per program, and against yesterday), channels released in the recent past, channels forecast to free up soon, the planned measurements queued next, plans that should already have started, equipment service — channels and whole cyclers out, calibration falling due, and booked visits — and healthy channels sitting idle. The response carries every section as structured data *and* the whole thing as a Markdown document in ``markdown``, so a caller can convert it to PDF or paste it into a message without re-deriving anything. This endpoint only builds the report; it does not deliver it anywhere. # Get the lab-view occupancy status for a project Source: https://docs.ionworks.com/api-reference/lab/get-the-lab-view-occupancy-status-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/status Return the project's sites, cyclers, and channels with occupancy state. Each channel carries a derived ``free`` / ``occupied`` / ``stale`` state (there is no live telemetry; state comes from linked measurements' ``end_time`` and update recency). Includes per-cycler and project-level occupancy counts. Each occupied/stale channel's measurement carries a ``watched`` flag for the requesting user, so the "My Channels" filter needs no extra round-trip. # List the measurement IDs the current user is watching Source: https://docs.ionworks.com/api-reference/lab/list-the-measurement-ids-the-current-user-is-watching https://api.ionworks.com/openapi.json get /projects/{project_id}/lab/watched Return the IDs of measurements the current user is watching. A flat list of IDs for membership testing on the client. Includes watches on measurements that may since have finished; the Lab wall intersects them with its live channels, so finished watches simply do not surface. # Get Optimization With Job Source: https://docs.ionworks.com/api-reference/optimization/get-optimization-with-job https://api.ionworks.com/openapi.json get /optimize/{id} Get an optimization and its linked job by optimization id or job_id. # Get Optimization With Jobs Source: https://docs.ionworks.com/api-reference/optimization/get-optimization-with-jobs https://api.ionworks.com/openapi.json get /optimize List optimization jobs and optimization records for the project. Filter parameters support Supabase filter operators: - Text fields (name, template_name, cell_name, model_name, created_by_email): use ``ilike.%value%`` for partial match. - Status: use ``eq.completed`` or ``in.(pending,running)`` for multiple values. - Date range: use ``created_at_gt`` and ``created_at_lt`` for between queries. # Cancel Optimization Source: https://docs.ionworks.com/api-reference/optimizations/cancel-optimization https://api.ionworks.com/openapi.json post /optimizations/{id}/cancel Cancel an optimization and return the refreshed resource. # Get Optimization Config Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-config https://api.ionworks.com/openapi.json get /optimizations/{id}/config Return the resolved config blob for an optimization. Read server-side from storage and sanitised (NaN/Infinity -> null) so clients no longer fetch the job params blob from storage directly. # Get Optimization Metadata Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-metadata https://api.ionworks.com/openapi.json get /optimizations/{id}/metadata Return the metadata blob for an optimization (server-side, sanitised). # Get Optimization Resource Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-resource https://api.ionworks.com/openapi.json get /optimizations/{id} Get a single optimization as a job-free resource. Accepts an optimization id (or, transitionally, a job_id) and returns an :class:`OptimizationResource` with flat status and run count. # Get Optimization Resource Statuses Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-resource-statuses https://api.ionworks.com/openapi.json get /optimizations/statuses Return lightweight statuses for the given optimization ids. For list polling: ``ids`` is a comma-separated list. Returns only id + domain status (no config, results, or runs). # Get Optimization Series Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-series https://api.ionworks.com/openapi.json get /optimizations/{id}/series One objective's validation series, windowed and budgeted. The same rows ``/metadata`` carries whole. A plot reading them from there downloads every sample of both solves to draw a few hundred points. # Get Optimization Series Channels Source: https://docs.ionworks.com/api-reference/optimizations/get-optimization-series-channels https://api.ionworks.com/openapi.json get /optimizations/{id}/series/channels Objectives and channel names for this optimization's validation series. Scoped to the optimization rather than the job, because the job's id is not something a client can see: the optimization resource does not carry it, and the adapter's `job_id` is the optimization's own id kept for legacy lookups. Resolving it here is the same step ``/metadata`` already takes. # List Optimization Resources Source: https://docs.ionworks.com/api-reference/optimizations/list-optimization-resources https://api.ionworks.com/openapi.json get /optimizations List optimizations as job-free resources for the project. Same filtering as the legacy list endpoint, but each item is an :class:`OptimizationResource` (flat status, no linked job). ``status`` filters still accept the underlying job-status vocabulary. # List Optimization Runs Source: https://docs.ionworks.com/api-reference/optimizations/list-optimization-runs https://api.ionworks.com/openapi.json get /optimizations/{id}/runs List the multistart runs of an optimization in one call. Each run carries status and convergence history (read server-side), so the frontend renders per-run progress without touching jobs. # Resubmit Optimization Source: https://docs.ionworks.com/api-reference/optimizations/resubmit-optimization https://api.ionworks.com/openapi.json post /optimizations/{id}/resubmit Resubmit an optimization's failed job; return the refreshed resource. # Submit Optimization Source: https://docs.ionworks.com/api-reference/optimizations/submit-optimization https://api.ionworks.com/openapi.json post /optimizations Submit a battery design optimization job using JSON input. # Update Optimization Source: https://docs.ionworks.com/api-reference/optimizations/update-optimization https://api.ionworks.com/openapi.json patch /optimizations/{id} Update an optimization's name and/or description. # Current organization usage and limits for the active billing period Source: https://docs.ionworks.com/api-reference/organizations/current-organization-usage-and-limits-for-the-active-billing-period https://api.ionworks.com/openapi.json get /organizations/current/usage Return the org-wide usage for the current calendar month and configured limits. Usage is aggregated across all members of the organization (limits are org-level) for the current calendar-month period and resets on the first of each month. Simulation usage is a single figure; compute usage is broken down by job type with the total in ``compute.usage``. All values are in hours; a ``None`` limit means that usage type is unconstrained. # Cancel a pipeline Source: https://docs.ionworks.com/api-reference/pipeline/cancel-a-pipeline https://api.ionworks.com/openapi.json post /pipelines/{pipeline_id}/cancel Cancel a running pipeline and all its non-terminal elements. # Cancel multiple pipelines Source: https://docs.ionworks.com/api-reference/pipeline/cancel-multiple-pipelines https://api.ionworks.com/openapi.json post /pipelines/cancel Cancel several pipelines in one request. Marks every selected pipeline (and its non-terminal elements) canceled and offloads all running-job terminations to a single background task, avoiding the per-pipeline request loop the frontend used to run. # Create a new pipeline Source: https://docs.ionworks.com/api-reference/pipeline/create-a-new-pipeline https://api.ionworks.com/openapi.json post /pipelines Creates a new pipeline definition with its associated steps. This endpoint defines the sequence of jobs but does not start execution immediately. Use the 'start' endpoint to begin the pipeline execution. # Get a pipeline element's job_config Source: https://docs.ionworks.com/api-reference/pipeline/get-a-pipeline-elements-job_config https://api.ionworks.com/openapi.json get /pipelines/elements/{element_id}/job_config Return one element's ``job_config``, hydrated lazily on expand. The bulk `/elements` response projects the bigger keys (`parameters`, `optimizer`, `cost`) out of every element so the details page loads fast; this fills them back in for the one element a drawer is showing. Returns ``{}`` for an element that does not exist or is not visible to the caller. Note this is the DB row, not the storage copy — the two differ only by `objectives`, the multi-MB serialized measurement payload the worker reads directly and no client needs. # Get Datafit Plot Data Source: https://docs.ionworks.com/api-reference/pipeline/get-datafit-plot-data https://api.ionworks.com/openapi.json get /pipelines/datafits/{job_id}/plot_data Return decimated trace data for a single data-fit validation plot. Same behaviour as ``GET /validations/{job_id}/plot_data`` but accepts a data-fit job ID. Both job types store ``validation_plot_config`` in the same metadata blob format. # Get pipeline metadata Source: https://docs.ionworks.com/api-reference/pipeline/get-pipeline-metadata https://api.ionworks.com/openapi.json get /pipelines/{pipeline_id} Retrieves pipeline metadata only (no elements). For elements, use GET /pipelines/{pipeline_id}/elements. # Get pipeline result Source: https://docs.ionworks.com/api-reference/pipeline/get-pipeline-result https://api.ionworks.com/openapi.json get /pipelines/{pipeline_id}/result Retrieves the details of a specific pipeline, including its steps and their status. # Get Validation Plot Data Source: https://docs.ionworks.com/api-reference/pipeline/get-validation-plot-data https://api.ionworks.com/openapi.json get /pipelines/validations/{job_id}/plot_data Return decimated trace data for a single validation or data-fit plot. Reads the stored ``validation_plot_config`` from the job's metadata blob, extracts the requested plot, filters to the given x-range, and downsamples to at most ``max_points`` per trace. Supports semantic zoom: callers refetch with ``x_min``/``x_max`` set to the current viewport on each zoom event. ``max_points`` is a per-trace ceiling, and a figure may hold several traces, so it is reduced where the whole response would otherwise exceed the payload cap — see ``src.jobs.plot_data._payload_capped_budget``. # List user's pipelines Source: https://docs.ionworks.com/api-reference/pipeline/list-users-pipelines https://api.ionworks.com/openapi.json get /pipelines Retrieves a list of pipelines for the current user, optionally filtered by project and other criteria. Text fields (name, description, created_by_email) take ``ilike.%value%``, status an exact value or ``in.(a,b)``, date fields ``gte.value`` / ``lte.value``, and created_at_gt/_lt (or updated_at_gt/_lt) bound a range. ``id`` stays alongside ``q``: it is the exact-UUID contract API clients rely on, while ``q`` matches a UUID only as one OR-term. # Apply selected auto-schedule assignments Source: https://docs.ionworks.com/api-reference/planned-measurements/apply-selected-auto-schedule-assignments https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements/apply_auto_schedule_proposal Turn reviewed proposal assignments into scheduled planned measurements. # Create a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/create-a-planned-measurement https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements Create a requested or scheduled planned measurement. # Delete a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/delete-a-planned-measurement https://api.ionworks.com/openapi.json delete /projects/{project_id}/planned_measurements/{planned_measurement_id} Delete a planned measurement. Not-found is idempotent. # Earliest free start time on a channel Source: https://docs.ionworks.com/api-reference/planned-measurements/earliest-free-start-time-on-a-channel https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements/next_available Return the earliest start time a channel is free for ``duration_seconds``. ``next_available_start`` is ``null`` when the channel is occupied by a running measurement with no known end time. # Estimate a planned measurement's duration from its protocol Source: https://docs.ionworks.com/api-reference/planned-measurements/estimate-a-planned-measurements-duration-from-its-protocol https://api.ionworks.com/openapi.json patch /projects/{project_id}/planned_measurements/{planned_measurement_id}/estimate_duration Work out how long this planned test will take, before it runs. Simulates the whole protocol with the cell specification's default model and writes the total into ``estimated_duration_seconds`` -- the value scheduling books channel windows off, which until now was always typed in by hand. ``buffer_pct`` pads the simulated figure by that percentage before booking it, defaulting to ``DEFAULT_BUFFER_PCT``. A protocol whose duration is fixed by the protocol alone is never padded. Returns immediately with ``estimated_duration_status`` set to ``estimating``; the job worker fills the duration in when the simulation finishes. Poll the plan, or watch the status. When the simulation was already run for an identical protocol and model, the estimate comes back ``ready`` instead. For a test that has already started, use the cell measurement's ``estimate_end_time`` endpoint instead: that one replays the measured steps and forecasts only what remains. # Get a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/get-a-planned-measurement https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements/{planned_measurement_id} Get a planned measurement by id within a project. # List planned measurements for a project Source: https://docs.ionworks.com/api-reference/planned-measurements/list-planned-measurements-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/planned_measurements List a project's planned measurements. ``name`` supports Supabase filter operators (e.g. ``ilike.%formation%`` for a partial match, ``eq.Formation`` for an exact match). ``channel_id`` and ``protocol_id`` are exact match. ``q`` is the search box's free-text query, which also matches the related names the table shows; it composes with the column filters above. # Propose reservations for selected requested tests Source: https://docs.ionworks.com/api-reference/planned-measurements/propose-reservations-for-selected-requested-tests https://api.ionworks.com/openapi.json post /projects/{project_id}/planned_measurements/auto_schedule_proposal Return a transient earliest-gap schedule for the selected test requests. # Update a planned measurement Source: https://docs.ionworks.com/api-reference/planned-measurements/update-a-planned-measurement https://api.ionworks.com/openapi.json patch /projects/{project_id}/planned_measurements/{planned_measurement_id} Patch a planned measurement. # Create a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/create-a-project-webhook https://api.ionworks.com/openapi.json post /projects/{project_id}/webhooks Create a webhook; returns the signing secret once. # Delete a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/delete-a-project-webhook https://api.ionworks.com/openapi.json delete /projects/{project_id}/webhooks/{webhook_id} Delete a webhook and cascade its delivery history. # Enqueue a test webhook delivery Source: https://docs.ionworks.com/api-reference/project-webhooks/enqueue-a-test-webhook-delivery https://api.ionworks.com/openapi.json post /projects/{project_id}/webhooks/{webhook_id}/test Enqueue a signed test ping to the webhook URL. # List recent deliveries for a webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/list-recent-deliveries-for-a-webhook https://api.ionworks.com/openapi.json get /projects/{project_id}/webhooks/{webhook_id}/deliveries Return recent delivery history for a webhook. # List webhooks for a project Source: https://docs.ionworks.com/api-reference/project-webhooks/list-webhooks-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/webhooks List project webhooks (signing secrets omitted). # Update a project webhook Source: https://docs.ionworks.com/api-reference/project-webhooks/update-a-project-webhook https://api.ionworks.com/openapi.json patch /projects/{project_id}/webhooks/{webhook_id} Partially update URL, events, or enabled flag. # Check Protocol Name Endpoint Source: https://docs.ionworks.com/api-reference/protocols/check-protocol-name-endpoint https://api.ionworks.com/openapi.json post /protocols/check_name Check whether a protocol's name describes what the protocol does. Advisory only. A 'mismatch' never blocks saving a protocol — the name is the user's to choose, and a model that misreads a naming convention must not be able to stop work. Returns 'uncheckable' for a name that makes no claim (a codename like "ETR874", a generic "Test", or a name carried in from an uploaded vendor file), so callers can render nothing at all in those cases. Parameters ---------- body : CheckProtocolNameRequest The name, the protocol it names, and whether the name came from a file. Returns ------- CheckProtocolNameResponse The verdict, its confidence, the specific contradictions, and a suggested name. # Compare Protocols Endpoint Source: https://docs.ionworks.com/api-reference/protocols/compare-protocols-endpoint https://api.ionworks.com/openapi.json post /protocols/compare Compare two UCPs and report the differences that change what a cycler does. Cosmetic respellings are not differences: regenerated names, number formatting, redundant nesting, an explicit goto where fall-through was implicit, and a dropped trailing ``End`` all leave behaviour unchanged. What is reported is what reaches the cell -- loop counts, setpoints, terminations, gotos, safety bounds, and block-cumulative durations. Pair it with ``/protocols/convert`` to check a round trip: convert a UCP to a vendor format, parse the result back with ``/protocols/parse``, and compare the two UCPs. A difference there is a conversion that lost something. # Convert Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/convert-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/convert Convert a UCP to a vendor-native protocol file. The primary artifact is returned as base64-encoded bytes alongside a suggested filename and media type. Some targets (currently Maccor with drive cycles) emit additional asset files in ``assets``. # Find Input References Endpoint Source: https://docs.ionworks.com/api-reference/protocols/find-input-references-endpoint https://api.ionworks.com/openapi.json post /protocols/input_references Find all input references from a protocol. This endpoint extracts all input references of the form input["name"] from the provided protocol. The protocol can be provided as a dictionary (JSON), YAML string, or Protocol object serialized as dict. Parameters ---------- body : FindInputReferencesRequest Request containing the protocol to analyze Returns ------- list[str] List of input reference names found in the protocol Raises ------ BadRequestError If protocol parsing fails or input references cannot be extracted # Generate Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/generate-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/generate Generate a UCP protocol from a natural-language description. When the description leaves a value undecided that would change what runs on the channel, the response carries ``questions`` and no protocol. Answer them and call again with the full ``clarifications`` history. Any protocol that *is* returned has already passed the same validation as ``POST /protocols/validate`` and contains no unresolved ``input["..."]`` references, so it can be saved as an experiment template directly. Parameters ---------- body : GenerateProtocolRequest The natural-language prompt, any answers already given, and an optional existing protocol to edit rather than replace. Returns ------- GenerateProtocolResponse Either the generated YAML with a suggested name, an explanation, and the assumptions made — or the questions needed to write it. Raises ------ ExternalServiceError If the model could not produce a valid protocol. # Validate Protocol Endpoint Source: https://docs.ionworks.com/api-reference/protocols/validate-protocol-endpoint https://api.ionworks.com/openapi.json post /protocols/validate Validate a protocol dictionary. Runs the full backend validation and returns the result plus advisory lint findings. A protocol can be valid and still carry warnings: operator and end-type mistakes no longer reject it, since one imported from a real cycler file may legitimately contain them. They come back in ``warnings`` so the author sees the problem without being blocked. Parameters ---------- body : ValidateProtocolRequest Request containing the protocol dict to validate Returns ------- dict ``{"valid": true, "warnings": [...]}`` on success, or ``{"valid": false, "error": "..."}`` on failure. ``warnings`` is omitted when there are none. # Cancel a running SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/cancel-a-running-simplepipeline https://api.ionworks.com/openapi.json post /simple_pipelines/{simple_pipeline_id}/cancel Cancel a running SimplePipeline. # Create and submit a SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/create-and-submit-a-simplepipeline https://api.ionworks.com/openapi.json post /simple_pipelines Create a new SimplePipeline and submit it for processing. # Delete a SimplePipeline Source: https://docs.ionworks.com/api-reference/simple_pipeline/delete-a-simplepipeline https://api.ionworks.com/openapi.json delete /simple_pipelines/{simple_pipeline_id} Delete a SimplePipeline and its associated job. # Get SimplePipeline by ID Source: https://docs.ionworks.com/api-reference/simple_pipeline/get-simplepipeline-by-id https://api.ionworks.com/openapi.json get /simple_pipelines/{simple_pipeline_id} Get a SimplePipeline by ID. # List SimplePipelines Source: https://docs.ionworks.com/api-reference/simple_pipeline/list-simplepipelines https://api.ionworks.com/openapi.json get /simple_pipelines List SimplePipelines for a project with pagination, filters, and ordering. # Update SimplePipeline name/description Source: https://docs.ionworks.com/api-reference/simple_pipeline/update-simplepipeline-namedescription https://api.ionworks.com/openapi.json patch /simple_pipelines/{simple_pipeline_id} Partially update a SimplePipeline's name and/or description. # Assign a measurement to a study Source: https://docs.ionworks.com/api-reference/studies/assign-a-measurement-to-a-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies/{study_id}/measurements/{measurement_id} Assign a measurement to a study. # Assign a simulation to a study Source: https://docs.ionworks.com/api-reference/studies/assign-a-simulation-to-a-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies/{study_id}/simulations/{simulation_id} Assign a simulation to a study. Idempotent: returns the existing mapping (200) if already assigned. # Create a new study Source: https://docs.ionworks.com/api-reference/studies/create-a-new-study https://api.ionworks.com/openapi.json post /projects/{project_id}/studies Create a new study associated with a specific project. # Delete a study Source: https://docs.ionworks.com/api-reference/studies/delete-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id} Delete a study by its ID. # Get a specific study by ID Source: https://docs.ionworks.com/api-reference/studies/get-a-specific-study-by-id https://api.ionworks.com/openapi.json get /projects/{project_id}/studies/{study_id} Retrieve a specific study by its unique ID. # List measurements assigned to a study Source: https://docs.ionworks.com/api-reference/studies/list-measurements-assigned-to-a-study https://api.ionworks.com/openapi.json get /projects/{project_id}/studies/{study_id}/measurements Retrieve measurements assigned to a study with pagination. # List studies for a project Source: https://docs.ionworks.com/api-reference/studies/list-studies-for-a-project https://api.ionworks.com/openapi.json get /projects/{project_id}/studies Retrieve studies associated with a specific project with pagination. # Remove a measurement from a study Source: https://docs.ionworks.com/api-reference/studies/remove-a-measurement-from-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id}/measurements/{measurement_id} Remove a measurement assignment from a study. # Remove a simulation from a study Source: https://docs.ionworks.com/api-reference/studies/remove-a-simulation-from-a-study https://api.ionworks.com/openapi.json delete /projects/{project_id}/studies/{study_id}/simulations/{simulation_id} Remove a simulation assignment from a study. # Update a study Source: https://docs.ionworks.com/api-reference/studies/update-a-study https://api.ionworks.com/openapi.json patch /projects/{project_id}/studies/{study_id} Update an existing study. # Get Current User Profile Source: https://docs.ionworks.com/api-reference/users/get-current-user-profile https://api.ionworks.com/openapi.json get /users/me