# Cell Measurements Measurements store time-series data recorded from testing a {doc}`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 | ```python 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 ```python 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: ```python 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: ```python 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. ```python # 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 ```python measurements = client.cell_measurement.list(cell_instance.id) for m in measurements: print(m.name) ``` ## Retrieving a measurement ```python # 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: ```python # 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: ```python 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 ```python updated = client.cell_measurement.update( measurement_id, { "notes": "Updated notes", }, ) ``` ## Deleting a measurement ```python 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: ```python 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`: ```python measurement = client.cell_measurement.get(measurement_id) measurement.url # https://app.ionworks.com/projects//data/measurements/ measurement.api_url # https://api.ionworks.com/cell_measurements/ ``` 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: ```python # 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 {doc}`pipelines`, or run forward simulations in {doc}`simulations`.