| name | manage-cells |
| description | Work with battery cell data: specifications, instances, and measurements. Use when creating, reading, updating, or deleting cell specs, instances, or measurements, or when fetching time series, steps, or cycles data. Triggers: "cell spec", "cell instance", "measurement", "time series", "steps", "cycles", "battery data", "cell data". |
Cell Data
Guide for working with the cell data hierarchy:
cell specifications, cell instances, and cell
measurements (including time series, steps, and cycles).
Prerequisites
Always run the discover-api skill first to fetch
capabilities and the data schema.
Data Hierarchy
cell_specification (blueprint: chemistry, form factor, ratings, components)
-> cell_instance (physical cell: serial number, batch, manufacturing date)
-> cell_measurement (test result: time_series | file | properties)
-> time_series (high-res voltage/current/time data)
-> steps (per-step summary: duration, capacity, voltage stats)
-> cycles (per-cycle metrics: capacity, efficiency, energy)
Components and Materials
Alongside the spec → instance → measurement hierarchy, there is a
separate component / material model that describes what a cell is
made of:
material (a substance: name, manufacturer, product_id — e.g. "NMC811")
^ referenced by
component (a part of a cell: component_type + material_id + properties)
^ referenced by
cell_specification (links one component per slot:
anode_id, cathode_id, electrolyte_id, separator_id, case_id)
Read the chain top-down: a spec links up to five components via its
slots — anode_id, cathode_id, electrolyte_id, separator_id,
case_id — and each component (a component_type such as anode,
cathode, electrolyte, separator, case, or cell) references
exactly one material. A material can be reused by many components
across many specs — this is the relationship that matters for
reverse lookups.
Key facts:
- A material is shared. The same
NMC811 material is referenced by
the cathode component of every spec that uses it. Materials are
org-wide (plus shared system materials), not owned by one spec.
- A component is the join. It carries
component_type,
material_id, and a freeform properties dict (geometry etc.). The
spec points at the component, the component points at the material.
- No server-side "specs by material" filter exists. Reverse lookups
(material → components → specs) are done client-side by walking
the relationships — see "What cells use this material?" below.
The down-the-chain write path (attaching geometry to a spec's
components) is covered in Electrode geometry (teardown) on the spec.
Cell Specifications
A cell specification defines a cell type: chemistry,
form factor, rated capacity, voltage limits, and
component materials.
from ionworks import Ionworks
client = Ionworks()
specs = client.cell_spec.list(
name="LGM50",
form_factor="cylindrical",
order_by="created_at",
order="desc",
limit=10,
)
specs = client.cell_spec.list(include_components=True)
spec = client.cell_spec.get("spec-id")
spec = client.cell_spec.create({
"name": "LGM50 Demo",
"form_factor": "cylindrical",
"ratings": {
"capacity": {"value": 5.0, "unit": "A.h"},
"voltage_min": {"value": 2.5, "unit": "V"},
"voltage_max": {"value": 4.2, "unit": "V"},
},
"cathode": {"material": {"name": "NMC811"}},
"anode": {"material": {"name": "Graphite-SiOx"}},
})
spec = client.cell_spec.create_or_get({
"name": "LGM50 Demo",
"ratings": {
"capacity": {"value": 5.0, "unit": "A.h"},
"voltage_min": {"value": 2.5, "unit": "V"},
"voltage_max": {"value": 4.2, "unit": "V"},
},
})
spec = client.cell_spec.update("spec-id", {"form_factor": "pouch"})
client.cell_spec.delete("spec-id")
Electrode geometry (teardown) on the spec
Electrode geometry — thickness, porosity, particle radius,
active-material fraction, current-collector thickness — is
design metadata stored on the cell spec's components, not
a measurement. A physics-based model (DFN/SPMe) cannot be
built or solved without it (missing thickness raises a build
error), so attaching geometry is what makes a spec
FPBM-ready. The source can be a teardown, direct metrology,
the vendor datasheet, or literature — the model only needs the
numbers; record provenance in each component's properties.
The model is: a spec links components (anode, cathode,
electrolyte, separator, case, cell), each pointing to a
material and carrying a freeform properties dict in
Quantity ({"value", "unit"}) format. Attach via a nested
PATCH on the spec — the platform creates the component +
material records and links them:
client.patch(f"/cell_specifications/{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": "teardown / datasheet",
},
},
"cathode": {"material": {"name": "M83E300"}, "properties": {...}},
"separator": {"material": {"name": "..."}, "properties": {...}},
})
Use human units (um, percent, mm) in PyBaMM notation
(.-separated atoms with signed integer exponents, e.g. mg.cm-2);
Pint-style strings (mg/cm**2) are also accepted and normalized.
After PATCH, spec.anode_id / cathode_id /
separator_id are populated; read a component back with
client.get(f"/cell_components/{cid}"). To check whether a
spec is FPBM-ready, inspect these IDs and spec.properties:
all null/empty means no geometry and a physics model
cannot run yet. Direct component/material endpoints
(/cell_components, /materials, /material_property_datasets)
exist if you need to manage them independently of a spec PATCH.
Reading materials and components directly
materials = client.material.list()
nmc = next((m for m in materials if m.name.casefold() == "nmc811"), None)
material = client.material.get("material-id")
components = client.get("/cell_components?component_type=cathode")
if nmc is not None:
cathodes_using_nmc = [c for c in components if c["material_id"] == nmc.id]
"What cells use this material?" (reverse lookup)
When a user asks "what cells use this material?" they mean: find the
specs (and optionally the instances) whose components reference that
material. There is no single material→specs endpoint, so map it onto
the relationship above — walk it backwards client-side.
List every spec with components inlined (include_components=True)
and match on the nested material name across all component slots. With
include_components=True the nested components come back as dicts
(the client model is permissive), so use dict access —
spec.cathode["material"]["name"], not attribute access.
Page through everything. list() returns a single
PaginatedList page, not the whole table — iterating it only
visits .items, and .total tells you how many rows exist in
total. A reverse lookup must scan all specs, so page until
you've collected .total rows, otherwise you silently miss specs
beyond the first page. The fetch_all helper below does this:
client = Ionworks()
def fetch_all(list_fn, page_size=500, **kwargs):
"""Page through a paginated list() endpoint until exhausted."""
items, offset = [], 0
while True:
page = list_fn(limit=page_size, offset=offset, **kwargs)
items.extend(page)
offset += len(page)
if offset >= page.total or not len(page):
return items
target = "NMC811"
specs = fetch_all(client.cell_spec.list, include_components=True)
def spec_uses_material(spec, name):
want = name.casefold()
for slot in ("anode", "cathode", "electrolyte", "separator", "case"):
comp = getattr(spec, slot, None)
mat_name = ((comp or {}).get("material") or {}).get("name") if comp else None
if mat_name and mat_name.casefold() == want:
return True
return False
matches = [s for s in specs if spec_uses_material(s, target)]
for s in matches:
print(s.id, s.name)
The walk above is the recommended approach because every list it
touches can be paged. If instead you have the material's ID and want
the explicit two-hop join (material → component IDs → specs), note one
caveat: /cell_components is a raw list endpoint that exposes no
limit/offset, so it returns at most the server's default page
(~1000 rows) and cannot be fully paged from the client. On an org
with more components than that, this variant can miss matches — prefer
the inlined-spec walk above when completeness matters.
nmc = next(
(m for m in fetch_all(client.material.list)
if m.name.casefold() == "nmc811"), None
)
if nmc is None:
raise ValueError("No material named NMC811")
components = client.get("/cell_components")
component_ids = {c["id"] for c in components if c["material_id"] == nmc.id}
specs = fetch_all(client.cell_spec.list)
slots = ("anode_id", "cathode_id", "electrolyte_id", "separator_id", "case_id")
matches = [
s for s in specs
if any(getattr(s, slot, None) in component_ids for slot in slots)
]
To go one step further — which physical cells (instances) use the
material — take each matching spec and list its instances:
for spec in matches:
instances = fetch_all(lambda **kw: client.cell_instance.list(spec.id, **kw))
for inst in instances:
print(spec.name, "->", inst.name)
Cell Instances
A cell instance is a specific physical cell built from
a specification.
instances = client.cell_instance.list(
"spec-id",
name="SN-001",
order_by="name",
order="asc",
)
instance = client.cell_instance.get("instance-id")
instance = client.cell_instance.create("spec-id", {
"name": "SN-001",
"batch": "2025-Q1",
"manufacturing_date": "2025-03-15",
})
instance = client.cell_instance.create_or_get("spec-id", {
"name": "SN-001",
})
detail = client.cell_instance.detail(
"instance-id",
include_steps=True,
include_cycles=True,
include_time_series=True,
)
Cell Measurements
A measurement is a test performed on a cell instance.
Three types: time_series, file, properties.
measurements = client.cell_measurement.list(
"instance-id",
measurement_type="time_series",
name="discharge_1C",
order_by="created_at",
order="desc",
)
meas = client.cell_measurement.get("measurement-id")
detail = client.cell_measurement.detail("measurement-id")
meas = client.resolve_measurement("LGM50 Demo", "Cell #1", "discharge_1C")
Fetching Tabular Data
Inventorying what data exists ≠ downloading it. To answer "what
measurements does this cell have / which model can I fit", use the
metadata calls — client.cell_measurement.list(instance_id, ...) and
client.cell_measurement.get(id) (name, measurement_type, properties).
Do not call .detail(), .time_series(), or .steps() for an
inventory — those download the full parquet from storage (slow, and
unnecessary just to learn what exists). Pull the raw signals only when you
actually need to inspect or plot them.
For time_series measurements, fetch data individually:
ts = client.cell_measurement.time_series("measurement-id")
steps = client.cell_measurement.steps("measurement-id")
cycles = client.cell_measurement.cycles("measurement-id")
sc = client.cell_measurement.steps_and_cycles("measurement-id")
ts = client.cell_measurement.time_series("measurement-id", use_cache=False)
Time Series Columns
Standard columns (from client.schema("data")):
Time [s] (required)
Current [A] (required)
Voltage [V] (required)
Step count (required)
Cycle count, Charge capacity [A.h],
Discharge capacity [A.h], Charge energy [W.h],
Discharge energy [W.h], Power [W],
Temperature [degC] (optional)
Additional columns are preserved as-is.
File Measurements
filenames = client.cell_measurement.list_files("measurement-id")
files = client.cell_measurement.download_files("measurement-id")
files = client.cell_measurement.download_files(
"measurement-id",
filenames=["image1.png"],
)
data = client.cell_measurement.get_file("measurement-id", "image1.png")
Linking to the Web App
To build a URL to a measurement detail page on
https://app.ionworks.com, use client.urls:
url = client.urls.measurement(meas.id, project_id)
client.urls.cell_specs(project_id)
client.urls.cell_instances(spec.id, project_id)
project_id is optional — it defaults to the client's project_id
(IONWORKS_PROJECT_ID). Use these instead of hand-constructing app URLs.
DataFrame Backend
By default, all tabular data is returned as Polars
DataFrames. Switch to pandas:
from ionworks import set_dataframe_backend
set_dataframe_backend("pandas")
Or set IONWORKS_DATAFRAME_BACKEND=pandas in the env.
Navigating the Hierarchy by Name
client = Ionworks()
specs = client.cell_spec.list(name_exact="LGM50 Demo")
spec = specs[0]
instances = client.cell_instance.list(spec.id, name_exact="SN-001")
inst = instances[0]
measurements = client.cell_measurement.list(
inst.id, name_exact="rate_capability_2C"
)
meas = measurements[0]
ts = client.cell_measurement.time_series(meas.id)