Skip to main content
The ionworks-api Python package lets you run simulations and submit parameterization pipelines programmatically. For installation and authentication, see the Python API client page.

Running simulations

Use client.simulation to run simulations. A simulation requires a parameterized model and a protocol in UCP format.

Single simulation

response = client.simulation.protocol({
    "parameterized_model": "your-parameterized-model-id",
    "protocol_experiment": {
        "protocol": """
global:
  initial_soc: 1
  temperature: 25
steps:
  - direction: Discharge
    mode: C-rate
    value: 1
    ends:
      - variable: Voltage [V]
        value: 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:
response = client.simulation.protocol({
    "parameterized_model": "your-parameterized-model-id",
    "protocol_experiment": {
        "protocol": """
global:
  initial_soc: input["Initial SOC"]
steps:
  - direction: Discharge
    mode: C-rate
    value: input["C-rate"]
    ends:
      - variable: Voltage [V]
        value: 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 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.
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:
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:
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:
TypeFieldsDescription
discretevaluesSpecific values to test
rangemin, max, countEvenly spaced values between min and max
normalmean, std, countValues sampled around the mean
Sampling strategies: grid (all combinations), random, latin_hypercube.

Retrieving simulation data

# 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:
FieldTypeDescription
result.time_seriesDataFrameOne row per time point; columns are signal names (e.g. "Time [s]", "Voltage [V]", "Current [A]").
result.stepsDataFrameOne row per protocol step (e.g. "Step count", "Step type", "Duration [s]").
result.metricsdict[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") once at session start to receive pandas DataFrames instead.
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 combine data fitting, calculations, and validation steps for battery model parameterization. Use client.pipeline to submit and manage pipelines.
Pipelines require a project_id. Set the IONWORKS_PROJECT_ID environment variable (or pass project_id= to Ionworks(...)) to configure a default project, or include project_id in the pipeline config explicitly. The deprecated PROJECT_ID env var is still accepted as a fallback.

Submitting a pipeline

# project_id is auto-injected from IONWORKS_PROJECT_ID or the client default;
# pass it explicitly in the config to override.
pipeline = client.pipeline.create({
    "name": "NMC622 Parameterization",
    "elements": {
        "entry": {
            "element_type": "entry",
            "values": {
                "model": {"type": "SPM"},
                "data": "db:your-measurement-id",
            },
        },
        "data_fit": {
            "element_type": "data_fit",
            "objectives": {"voltage": {"data": "entry.data"}},
            "parameters": {
                "Negative electrode diffusivity [m2.s-1]": {
                    "bounds": [1e-16, 1e-12],
                    "initial_value": 3.3e-14,
                }
            },
        },
    },
})

print(f"Pipeline ID: {pipeline.id}")
print(f"Status: {pipeline.status}")
Pipeline elements must be a dictionary (not a list). Each key is the element name and the value is its configuration.

Waiting for pipeline completion

result = client.pipeline.wait_for_completion(
    pipeline.id,
    timeout=600,        # seconds (default: 600)
    poll_interval=2,    # seconds between polls (default: 2)
    verbose=True,
)

print(f"Final status: {result.status}")

Getting pipeline results

pipeline_result = client.pipeline.result(pipeline.id)

# Access fitted parameter values
for name, element in pipeline_result.element_results.items():
    print(f"{name}: {element}")

Data references in pipelines

Use these prefixes to reference data sources in pipeline configs:
PrefixExampleDescription
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.
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

Pipeline configs accept PyBaMM model objects directly. The client auto-serializes them before sending:
import pybamm

pipeline = client.pipeline.create({
    "elements": {
        "entry": {
            "element_type": "entry",
            "values": {
                "model": pybamm.lithium_ion.SPM(),
            },
        },
    },
})

Running simple pipelines

For pipelines that contain a single data fit or a single validation step, use client.simple_pipeline instead of client.pipeline. A simple pipeline is a lightweight, fire-and-forget alternative: you submit one config and the server runs it end-to-end as a single job, returning a flat parameter_values result.

When to use simple pipelines

Use client.simple_pipeline whenUse client.pipeline when
Your config has at most one data_fit or validation elementYour config chains multiple data fits, calculations, or validations
You want fire-and-forget execution with a single result payloadYou need per-element status tracking and intermediate results
You want a flat parameter_values dict backYou need cumulative parameter threading across elements
Simple pipelines require a project_id. Set the IONWORKS_PROJECT_ID environment variable (or pass project_id= to Ionworks(...)) to configure a default project, or pass project_id= explicitly on each call.

Submitting a simple pipeline

config = {
    "elements": {
        "initial_params": {
            "element_type": "entry",
            "values": {"Negative particle diffusivity [m2.s-1]": 2e-14},
        },
        "fit": {
            "element_type": "data_fit",
            "objectives": {"voltage": {"data": "db:your-measurement-id"}},
            "parameters": {
                "Negative particle diffusivity [m2.s-1]": {
                    "bounds": [1e-14, 1e-13],
                    "initial_value": 2e-14,
                }
            },
            "cost": {"type": "RMSE"},
            "optimizer": {"type": "DifferentialEvolution", "max_iterations": 10},
        },
    }
}

sp = client.simple_pipeline.create(
    config,
    name="NMC622 diffusivity fit",
    description="Single-parameter fit on the May 14 dataset",
)

print(sp.id, sp.status)  # status starts as "pending"
The elements dict may contain at most one data_fit or validation element. Helper entries such as entry elements are allowed and are evaluated before the fit.

Accepted element_type values

Each element’s element_type accepts the canonical wire values below. For backwards compatibility, configs authored against earlier versions of the app may also use the legacy display labels in parentheses — the server normalizes them to the canonical value before running the job.
Canonical valueLegacy aliasUse for
entry"Direct Entry"Seeding parameter values or model selection before the fit
data_fit"Data Fit", "datafit"The single fitting step
calculation"Calculation"Derived parameter calculations
validation"Validation"A single validation step (in place of a data_fit)
New configs should use the canonical values. An unrecognized element_type causes the job to fail with a ValueError listing the accepted values.
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.

Execution options

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.
OptionTypeDefaultEffect
live_progress_updatesbool | NoneNone (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.
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.
config = {
    "options": {"live_progress_updates": True},
    "elements": { ... },
}

sp = client.simple_pipeline.create(config)

Waiting for completion

wait_for_completion polls until the pipeline reaches a terminal status (completed, failed, or canceled) and returns the final record.
completed = client.simple_pipeline.wait_for_completion(
    sp.id,
    timeout=600,        # seconds (default: 600)
    poll_interval=2,    # seconds between polls (default: 2)
    verbose=True,
    raise_on_failure=True,
)

# Fitted parameter values are returned as a flat dict
print(completed.result["parameter_values"])
# {"Negative particle diffusivity [m2.s-1]": 5.3e-14}
For validation runs, the result also contains a summary_stats block alongside parameter_values. If the pipeline does not finish within timeout, a TimeoutError is raised. If it ends in failed and raise_on_failure=True (the default), an IonworksError is raised with the server-side error message.

Listing, filtering, and sorting

list returns a paginated response with items, count, and total. String filters accept either an exact value or Supabase operator syntax such as ilike.%foo% or in.(completed,failed).
# 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",
)

Updating, cancelling, and deleting

# 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

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:
    # Pipeline ended in "failed" — the message includes the server error
    print(e)

Managing studies

Use client.study to create, list, update, and delete 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 configured on the client (or resolved from IONWORKS_PROJECT_ID). Pass project_id= explicitly to override on a per-call basis.

Listing studies

# 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

study = client.study.get("your-study-id")

Creating a study

study = client.study.create({
    "name": "1C Discharge Sweep",
    "description": "Comparing discharge curves across temperatures",
})

Updating a study

study = client.study.update("your-study-id", {
    "name": "1C Discharge Sweep v2",
})

Assigning simulations and measurements

# 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

client.study.delete("your-study-id")

Managing protocols

Use client.protocol to validate UCP protocols.

Validating a protocol

result = client.protocol.validate("""
global:
  initial_soc: 1
  temperature: 25
steps:
  - direction: Discharge
    mode: C-rate
    value: 1
    ends:
      - variable: Voltage [V]
        value: 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.
refs = client.protocol.find_input_references("""
global:
  initial_soc: input["Initial SOC"]
steps:
  - direction: 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 — start from a protocol designed in Ionworks and produce a file you can run on hardware. Supported target formats: maccor, neware, arbin, biologic, novonix.
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_format="maccor",
)

# Write the converted protocol to disk
with open("protocol.000", "w") as f:
    f.write(result["content"])

print(result["filename"])  # e.g. "protocol.000"
Some UCP features map cleanly across all formats, but each vendor has its own syntax and limitations (see 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 for the specific features each target format rejects). Validate the output by reuploading it through the commercial protocol flow 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

Simulations

Learn about running simulations in Ionworks Studio.

Protocol reference

Full reference for the Universal Cycler Protocol format.

Uploading data

Upload and manage cell data via the Python API.

Build API

List and retrieve models and parameterized models.