# 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.
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:
| $\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