Cell Measurements¶
Measurements store time-series data recorded from testing a cell instance. Each measurement includes metadata (protocol, test setup, notes) and the raw time-series data itself.
Uploading a measurement is a single client call, but behind the scenes the client handles a three-step signed-URL upload process automatically.
Preparing time-series data¶
Time-series data is passed as a pandas or polars DataFrame (or a plain
dict of lists). The following columns are required:
Column |
Description |
|---|---|
|
Elapsed time in seconds |
|
Cell voltage |
|
Applied current |
|
Step index (zero-based) |
|
Cycle index (zero-based) |
|
Raw step number from the cycler |
|
Raw cycle number from the cycler |
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],
}
)
Uploading a measurement¶
bundle = client.cell_measurement.create(
cell_instance.id,
{
"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,
},
)
print(f"Measurement ID: {bundle.measurement.name}")
Steps are derived on the server after the record is created, so steps_created
is always 0 on the response. create() waits for that processing to finish and
raises MeasurementProcessingError if the file could not be read — a returned
result means the upload is usable, an exception means it is not.
Uploading a batch is the one case where that wait is worth moving out of the call. The server processes uploads concurrently, so submitting them all and then waiting on every id together takes about as long as the slowest single upload, where waiting inside each call takes the sum:
results = [
client.cell_measurement.create(cell_instance.id, detail, wait_for_processing=False)
for detail in details
]
client.cell_measurement.wait_for_processing([r.id for r in results])
wait_for_processing() applies one timeout across the whole batch and reports
every failure together — a rejected upload does not stop the rest being waited
on. Read MeasurementProcessingError.failures for the per-measurement reasons.
Use create_or_get() to skip the upload when a measurement with the same name
already exists on the instance:
result = client.cell_measurement.create_or_get(cell_instance.id, {
"measurement": {"name": "Formation Cycle 1", ...},
"time_series": time_series,
})
Extending a measurement¶
While a time-series test is still running, append new rows with
extend(). Pass only the delta (new points), not the full series.
The caller must ensure min(Time [s]) of the delta is strictly greater
than max(Time [s]) of the data already stored — no overlap.
The delta columns must exactly match the existing series columns (same set; order is aligned by the server). Steps are always recomputed from the stitched series — never pass a steps payload.
Stitching runs on the server after confirm returns, so it cannot time
out on large series. extend() waits for it to finish by default and
raises MeasurementProcessingError if the delta could not be applied;
pass wait_for_processing=False to return as soon as the work is
queued. Because the steps are recomputed afterwards, steps_created is
always 0 — read the measurement back if you need the new count.
A measurement can only be extended once its previous upload has
finished processing, and only one extend can be in flight at a time.
Both refusals come back as a 409 conflict, so retry after a wait
rather than treating them as fatal.
If a stitch fails, the canonical series is rolled back: the measurement
stays readable and reports ready, with processing_error explaining
why the delta did not land. A ready status alone therefore does not
confirm an extend applied — check processing_error when you skip the
wait.
You cannot extend a measurement once end_time is set. When the test
is finished, PATCH end_time via update() to mark it complete.
# Delta only — times continue past the last uploaded point
delta = pd.DataFrame(
{
"Time [s]": [6, 7, 8],
"Voltage [V]": [4.1, 4.05, 4.0],
"Current [A]": [-0.002, -0.002, -0.002],
"Step count": [2, 2, 2],
"Cycle count": [0, 0, 0],
"Step from cycler": [3, 3, 3],
"Cycle from cycler": [0, 0, 0],
}
)
# Blocks until the server has stitched the delta on.
client.cell_measurement.extend(measurement_id, delta)
# steps_created is always 0, so read the count back if you need it.
detail = client.cell_measurement.get(measurement_id)
print(f"Steps after extend: {len(detail.steps['Step count'])}")
# When the test is done, set end_time (blocks further extends)
client.cell_measurement.update(
measurement_id,
{
"end_time": "2025-03-15T18:00:00Z",
},
)
Listing measurements¶
measurements = client.cell_measurement.list(cell_instance.id)
for m in measurements:
print(m.name)
Retrieving a measurement¶
# Metadata only (no data)
measurement = client.cell_measurement.get(measurement_id)
Full measurement detail¶
detail() fetches metadata, steps, cycles, and time-series
data in parallel. Use the include_* flags to skip data you
don’t need:
# Everything (3 parallel requests + download)
detail = client.cell_measurement.detail(measurement_id)
print(detail.steps.shape)
print(detail.cycles.shape)
print(detail.time_series.shape)
# Metadata + steps/cycles only (no download)
detail = client.cell_measurement.detail(measurement_id, include_time_series=False)
Accessing steps, cycles, and time series individually¶
Each data type can also be fetched on its own:
steps = client.cell_measurement.steps(measurement_id)
cycles = client.cell_measurement.cycles(measurement_id)
ts = client.cell_measurement.time_series(measurement_id)
# Or fetch steps and cycles together (one call,
# more efficient than two separate calls):
sc = client.cell_measurement.steps_and_cycles(measurement_id)
print(sc.steps.shape, sc.cycles.shape)
Updating a measurement¶
updated = client.cell_measurement.update(
measurement_id,
{
"notes": "Updated notes",
},
)
Deleting a measurement¶
client.cell_measurement.delete(measurement_id)
DataFrame backend¶
By default, data is returned as polars DataFrames. To use pandas instead, set the backend at client creation or at any time:
import ionworks as iw
# At client creation
client = iw.api.Client(dataframe_backend="pandas")
# Or at any time
iw.api.set_dataframe_backend("pandas")
You can also set the IONWORKS_DATAFRAME_BACKEND environment
variable to "pandas" or "polars".
Linking to the web app¶
A fetched measurement carries its page in the Ionworks web app as url, and
the API endpoint that returns it as api_url:
measurement = client.cell_measurement.get(measurement_id)
measurement.url
# https://app.ionworks.com/projects/<project_id>/data/measurements/<measurement_id>
measurement.api_url
# https://api.ionworks.com/cell_measurements/<measurement_id>
The server builds both from the record, so you never need its project id.
Only get() responses carry them; list items and create responses do not.
When you hold just an id and want a link without a request, client.urls
builds one locally:
# Uses the project_id configured on the client
# (from `IONWORKS_PROJECT_ID` or the `project_id=` constructor arg)
url = client.urls.measurement(measurement_id)
# Or pass an explicit project_id to override the client default
url = client.urls.measurement(measurement_id, project_id="other-project")
Its web-app host is derived from the client’s api_url (api.ionworks.com →
app.ionworks.com); set IONWORKS_APP_URL to override, e.g. for local
development.
Next steps¶
With data uploaded you can run parameter fitting and analysis workflows in Pipelines, or run forward simulations in Simulations.