| name | manage-projects |
| description | Manage projects, studies, models, parameterized models, and optimizations. Use when the user wants to create or manage projects, organize simulations into studies, work with custom or parameterized models, or run optimizations. Triggers: "project", "study", "model", "parameterized model", "optimization", "custom model". |
Projects and Studies
Guide for managing projects, studies, models,
parameterized models, and optimizations.
Prerequisites
Run the discover-api skill first.
Idempotent create (create-or-get pattern)
Parameterized models have a create_or_get(cell_spec_id, data) method on the SDK (like cell specs, instances, and
measurements) — prefer it for re-runnable scripts.
Projects, studies, and models do not have a
create_or_get() method on the SDK. Use the
duplicate-error fallback pattern documented in the
discover-api skill ("Create-or-Get" section) — catch
IonworksError with error_code == "CONFLICT" and read
existing_id from e.data["detail"]. The same pattern
works for client.study.create / .get and
client.model.create / .get (the get signatures vary —
see each section below).
Projects
Projects group cell specifications and studies within
an organization.
from ionworks import Ionworks
client = Ionworks()
projects = client.project.list(
name="Battery",
order_by="created_at",
order="desc",
)
project = client.project.get("project-id")
project = client.project.create({
"name": "NMC Characterization",
"description": "Q1 2025 NMC cell testing",
})
project = client.project.update(
"project-id",
{"description": "Updated description"},
)
client.project.delete("project-id")
Studies
Studies group simulations and measurements within a
project for comparison and analysis.
studies = client.study.list(
"project-id",
name="Discharge",
order_by="name",
order="asc",
)
study = client.study.get("project-id", "study-id")
study = client.study.create(
"project-id",
{"name": "1C Discharge Study", "description": "..."},
)
study = client.study.update(
"project-id",
"study-id",
{"description": "Updated"},
)
client.study.delete("project-id", "study-id")
Study Mappings
Assign simulations and measurements to studies:
client.study.assign_simulation(
"project-id",
"study-id",
"sim-id",
)
client.study.remove_simulation(
"project-id",
"study-id",
"sim-id",
)
client.study.assign_measurement(
"project-id",
"study-id",
"meas-id",
)
result = client.study.list_measurements(
"project-id",
"study-id",
limit=50,
offset=0,
)
client.study.remove_measurement(
"project-id",
"study-id",
"meas-id",
)
Models vs Parameterized Models — disambiguation
When the user says "model" — or "models", "my models",
"list the models", "show me the model", "delete that
model" — they almost always mean a parameterized
model, not a bare Model. A bare Model is just the
structural/symbolic definition (e.g. "DFN", "SPM", or a
custom PyBaMM BaseModel); it holds no parameter values
and can't be simulated on its own. A ParameterizedModel
binds a Model to a cell spec and a parameter set —
that's the thing people actually create, list, run
simulations against, and refer to in day-to-day use.
Default to client.parameterized_model.* for any
bare "model" request, and reach for client.model.*
(the structural definitions) only when the user is
explicitly talking about the model structure —
"the DFN model", "a custom pybamm model", "the model
equations/options", "upload a model" (a BaseModel).
| User says… | Use |
|---|
| "list my models", "what models do I have" | client.parameterized_model.list_by_project(...) |
| "show / open / get model X" | client.parameterized_model.get(...) |
| "create / add a model" (for a chemistry/paper/dataset) | create a ParameterizedModel (workflow below) |
| "delete / rename that model" | client.parameterized_model.delete/update(...) |
| "which DFN/SPM/ECM structures exist", "upload a custom model" | client.model.* |
When genuinely ambiguous, prefer the parameterized-model
interpretation and say which one you assumed, rather
than asking.
Default workflow when asked to "add a model" for some
chemistry/paper/dataset:
- If a base
Model for that structure doesn't yet
exist, create or upload it (see below).
- Ensure a cell spec exists (create one if needed).
- Create a
ParameterizedModel on that cell spec
that points at the base Model and carries the
parameter dict.
Only stop at step 1 if the user explicitly asked for
just the model definition (no parameters, no cell
spec).
Models
A Model is the electrochemical model structure
(before parameterization). There are two ways to
represent one in this system, and they're not
interchangeable:
| Representation | What's stored | Created via | Use when… |
|---|
| PyBaMM built-in | config = {"pybamm_model": "DFN", "options": {…}} (or the equivalent {"type": "DFN", "module": "lithium_ion", "options": {…}} form). The backend constructs the class from the string at simulation time. | client.model.create({"name": …, "config": {…}}) — or already shipped as a system model. | The model is a stock pybamm subclass (SPM, SPMe, DFN, ECM, LumpedSPMR, …) with at most option tweaks. Goes through the most tested code path. |
| Custom upload | Full serialised pybamm.BaseModel JSON in the custom_model_data field; config is {}. | client.model.upload_custom(my_pybamm_model, …). | You wrote your own pybamm.BaseModel subclass with new equations — Marinescu2016 Li-S, a custom DFN with a brand-new submodel, a sodium-ion model, anything pybamm doesn't ship. |
Model.is_custom_model returns True for the second
case and False for the first. That's the distinction
that matters for downstream code.
System ownership (built-ins shipped with the platform
vs the org's own models) is orthogonal to this — both
shipped system models and org-created standard models
use the built-in representation, and you can tell them
apart by model.organization_id if needed.
Default to a config-based model
Before reaching for upload_custom, check whether you
can express your model as a config dict over a stock
pybamm or ionworks class. The discovery endpoint
returns the full catalog:
catalog = client.pybamm_models()
catalog["pybamm_models"]
catalog["ionworks_models"]["names"]
catalog["options"]["thermal"]
If your model is one of those classes — even with custom
options like {"thermal": "lumped", "SEI": "ec reaction limited"} — create it with client.model.create({...})
and skip the upload entirely. Only reach for
upload_custom when the model class itself isn't on
that list (new equations, new submodels, non-Li-ion
chemistries with their own state variables).
Option compatibility — not every (model, options) pair works
catalog["options"] lists every key in pybamm's
BatteryModelOptions and the full set of allowed
values for each, but not every option applies to
every model and not every combination is valid.
Examples:
"working electrode": "positive" only makes sense
for half-cell models (SPMHalfCell, DFNHalfCell,
…). Setting it on a full-cell DFN doesn't error
at construction but produces nonsense at simulation
time.
- Some
"particle" settings are mutually exclusive
with specific "thermal" choices.
- Ionworks-specific models (
LumpedSPMR, LumpedSPMeR)
accept a much narrower set of options than pybamm
full models — see the model's docstring.
Use the validate endpoint to check a specific
combination before you persist it:
result = client.validate_pybamm_model_config(
pybamm_model="SPM",
options={"thermal": "lumped", "SEI": "ec reaction limited"},
)
bad = client.validate_pybamm_model_config(
pybamm_model="SPM",
options={"thermal": "totally-not-a-thing"},
)
The validator builds the model in-process and drops it
— nothing is written to the database. Catches
unknown model names, unknown option keys, and option
values pybamm rejects at construction time. Some
incompatibilities (mostly semantic, like the
half-cell example above) only surface at simulation
time and won't be caught here.
You can also list what already exists in the
organization:
models = client.model.list()
config_based = [m for m in models.items if not m.is_custom_model]
uploaded = [m for m in models.items if m.is_custom_model]
client.model.list() accepts name=, name_exact=,
order_by=, order=, etc. for filtering. For the JSON
Schema of a model record (and the create_schema for
client.model.create), use client.schema("model").
Standard model CRUD
model = client.model.get("model-id")
model = client.model.create({
"name": "Custom SPM with SEI",
"description": "SPM + SEI growth submodel",
"config": {
"pybamm_model": "SPM",
"options": {"SEI": "ec reaction limited"},
},
})
model = client.model.add_custom_variable(
"model-id",
{"name": "Custom diffusivity", "expression": "..."},
)
model = client.model.update("model-id", {"description": "..."})
client.model.delete("model-id")
Uploading a custom PyBaMM model
When the model isn't a stock pybamm subclass — your own
equations in a pybamm.BaseModel subclass — upload it
with client.model.upload_custom:
my_model = MyCustomModel()
uploaded = client.model.upload_custom(
my_model,
name="My Custom Model",
chemistry="lithium_sulfur",
description="...",
)
assert uploaded.is_custom_model is True
The SDK serialises the model via PyBaMM's
Serialise().save_custom_model(...) (handling the
EventType-not-JSON-serialisable gotcha automatically)
and POSTs the file as multipart form data. You can also
pass an already-serialised JSON file path or an open
binary file object instead of a BaseModel instance.
Don't reach for upload_custom if a built-in fits —
the built-in path is more battle-tested and the model
record stays small (no embedded JSON blob).
chemistry field
Uploaded models carry a chemistry tag that tells the
simulation pipeline which assumptions to apply:
| Value | Use for |
|---|
lithium_ion (default) | Anything pybamm's lithium-ion machinery handles — SPM/SPMe/DFN subclasses, BPX, ECM half-cell wrappers using Li-ion params. |
lithium_sulfur | Li-S models (Marinescu2016, Cornish2021, Hua2019, etc.). Skips Li-ion-only enrichment, sets initial voltage via Initial voltage [V], and tightens IDAKLU tolerances to handle the small sulfur-species concentrations. |
ecm | Custom equivalent-circuit models. |
generic | Anything else. The pipeline will not apply any chemistry-specific fix-ups; the model is responsible for its own initial state, output variables, and parameter contract. |
If you upload a Li-S model without setting
chemistry="lithium_sulfur", the simulation will fail
with a lithium-ion lookup error ('Positive electrode OCP [V]' not found) when initial state is set from
voltage. Set the chemistry up-front; it's recorded on
the model row and can't be inferred reliably.
Once uploaded, custom models are used the same way as
built-in or standard custom models when creating
parameterized models and running simulations.
Parameterized Models
A parameterized model combines a model with specific
parameter values, attached to a cell specification.
pms = client.parameterized_model.list_by_cell_specification("cell-spec-id")
pms = client.parameterized_model.list_by_project("project-id")
pms = client.parameterized_model.list_by_project(
"project-id", cell_spec_id="cell-spec-id", limit=50,
)
pm = client.parameterized_model.get("pm-id")
pm = client.parameterized_model.create("cell-spec-id", {
"name": "LGM50 Chen2020",
"description": "Chen2020 parameters for LGM50",
"model_id": "model-id",
"parameters": { ... },
})
pm = client.parameterized_model.create_or_get("cell-spec-id", {
"name": "LGM50 Chen2020",
"model_id": "model-id",
"parameters": { ... },
})
pm = client.parameterized_model.update(
"cell-spec-id", "pm-id", {"name": "Renamed"},
)
params = client.parameterized_model.get_parameter_values("pm-id")
var_names = client.parameterized_model.get_variable_names("pm-id")
Optimizations
Run design optimization on parameterized models:
opt = client.optimization.run({
"name": "fast charge optimization",
"project_id": "project-id",
"parameterized_model_id": "pm-id",
})
result = client.optimization.wait_for_completion(
opt.id,
timeout=600,
poll_interval=3,
verbose=True,
)
opts = client.optimization.list(project_id="project-id")
opt_detail = client.optimization.get("optimization-id")
client.optimization.update("optimization-id", {"name": "Renamed"})
client.optimization.cancel("optimization-id")
Linking to the Web App
To point a user at a resource on https://app.ionworks.com, use
client.urls instead of hand-constructing URLs. project_id is optional
on every method — it defaults to the client's project_id
(IONWORKS_PROJECT_ID).
client.urls.project(project_id)
client.urls.model(model.id, project_id)
client.urls.parameterized_model(pm.id, project_id)
client.urls.optimization(opt.id, project_id)
client.urls.pipeline(pipeline.id, project_id)
client.urls.study(study.id, project_id)
client.urls.protocol(protocol.id, project_id)
client.urls.material(material.id, project_id)
client.urls.simulation(sim.id, pm.id, project_id)
client.urls.simulation(sim.id)