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

Time [s]

Elapsed time in seconds

Voltage [V]

Cell voltage

Current [A]

Applied current

Step count

Step index (zero-based)

Cycle count

Cycle index (zero-based)

Step from cycler

Raw step number from the cycler

Cycle from 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.