> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionworks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Uploading data

> Upload cell specifications, instances, and measurements to Ionworks Studio via the Python API

Uploading data to Ionworks Studio follows a three-step hierarchy: create the
cell specification (the cell's blueprint), create a cell instance (a specific
physical cell), then upload measurements against that instance.

For installation and authentication, see the
[Python API client](/api-client) page. For reading data back, see
[reading data](/data/reading).

## Upload workflow

<Steps>
  <Step title="Create or get a cell specification">
    The spec defines the cell's materials, electrical ratings, and components.
  </Step>

  <Step title="Create or get a cell instance">
    A specific physical cell, linked to a spec.
  </Step>

  <Step title="Upload measurements">
    Attach time series, properties, or file measurements to the instance.
  </Step>
</Steps>

### Step 1: Create or get a cell specification

```python theme={null}
cell_spec = client.cell_spec.create_or_get(
    {
        "name": "NCM622/Graphite Coin Cell",
        "form_factor": "R2032",
        "manufacturer": "Custom Cells",
        "ratings": {
            "capacity": {"value": 0.002, "unit": "A.h"},
            "voltage_min": {"value": 2.5, "unit": "V"},
            "voltage_max": {"value": 4.2, "unit": "V"},
        },
        "cathode": {
            "properties": {"loading": {"value": 12.3, "unit": "mg.cm-2"}},
            "material": {"name": "NCM622", "manufacturer": "BASF"},
        },
        "anode": {
            "properties": {"loading": {"value": 6.5, "unit": "mg.cm-2"}},
            "material": {"name": "Graphite", "manufacturer": "Customcells"},
        },
    }
)
```

See [Cells](/core-concepts/cells) for the full set of cell specification fields.

### Step 2: Create or get a cell instance

```python theme={null}
cell_instance = client.cell_instance.create_or_get(
    cell_spec.id,
    {
        "name": "NCM622-GR-001",
        "batch": "BATCH-2024-001",
        "date_manufactured": "2024-01-20",
        "measured_properties": {
            "cathode": {"loading": {"value": 12.1, "unit": "mg.cm-2"}},
            "anode": {"loading": {"value": 6.4, "unit": "mg.cm-2"}},
        },
    },
)
```

Cell instance fields:

| Field                 | Description                                                                                                  |
| --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `name`                | Unique identifier for this cell                                                                              |
| `batch`               | Batch or lot number                                                                                          |
| `date_manufactured`   | Manufacturing date (optional)                                                                                |
| `measured_properties` | Cell-specific measured values that differ from the spec (e.g., actual measured capacity, electrode loadings) |

### Step 3: Upload a measurement with time series

```python theme={null}
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],
    }
)

measurement_data = {
    "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,
}

bundle = client.cell_measurement.create(cell_instance.id, measurement_data)

print(f"Created measurement: {bundle.name}")
print(f"Steps created: {bundle.steps_created}")
```

For **properties** and **file** measurement types (and the fields they accept),
see [measurements](/data/measurements).

## Upload validation

Before any data is sent to Ionworks Studio, the Python client validates your
time series on your machine. The always-on checks catch the most common
processing mistakes:

* Positive current corresponds to discharge (voltage decreases); charging uses
  negative current
* Time starts at 0 and is monotonically non-decreasing
* `Step count` exists, starts at 0, and increments by 1
* Cumulative columns (capacity, energy) reset at the start of each step

If any of these fail, `create` and `create_or_get` raise a
`MeasurementValidationError` and no upload is initiated. See
[Troubleshooting](#troubleshooting) below for how to fix each failure.

### Inspecting validation failures

`MeasurementValidationError.errors` is a list of `ValidationIssue` records.
Each issue carries a stable `code` (an `IssueCode` enum), a `severity`
(`"error"` or `"warning"`), a human-readable `message`, and a structured
`payload` dict with details such as step indices, column names, observed
values, and thresholds.

Branch on `code` rather than parsing `message` — wording can change
between releases, but check identifiers are stable.

```python theme={null}
from ionworks import IssueCode, MeasurementValidationError

try:
    client.cell_measurement.create(cell_instance.id, measurement_data)
except MeasurementValidationError as e:
    for issue in e.errors:
        print(f"[{issue.severity}] {issue.code}: {issue.message}")
        print("  details:", issue.payload)

    if e.has_code(IssueCode.CURRENT_SIGN_REVERSED):
        # Auto-fixable: positive current was charge, not discharge.
        # Use ionworksdata.transform.set_positive_current_for_discharge(df)
        # before re-uploading.
        ...
```

### Strict validation

**We recommend passing `validate_strict=True` on every upload.** The strict
checks catch subtle bugs introduced during data processing — for example,
large unrecorded time gaps or a time series whose rows have been reordered by
a faulty `groupby` or `partition_by` call — before the data lands in Ionworks
Studio and contaminates downstream simulations and fits.

```python theme={null}
bundle = client.cell_measurement.create(
    cell_instance.id,
    measurement_data,
    validate_strict=True,
)
```

Strict mode adds the following checks on top of the always-on ones:

* Each step contains at least 2 data points
* `Cycle count` is constant within each step
* No gap between consecutive time samples exceeds 5 hours
* Voltage is continuous between consecutive rows (requires `voltage_window`)
* No two consecutive same-direction steps deliver a full charge or discharge
  (requires `rated_capacity` and a `steps` dataframe in `measurement_detail`)
* No single step exceeds 500 % of the rated capacity — emitted as a
  `UserWarning` rather than raising (requires `rated_capacity` and `steps`)
* Reported cumulative capacity and energy columns agree with the trapezoidal
  integral of current (and power) within 10 % at every row — catches
  mislabeled `Discharge`/`Charge` columns, dropped samples, and
  sign-convention bugs that survive the always-on checks

### Providing cell context for richer checks

The voltage-continuity and step-capacity checks need to know the cell's rated
voltage window and capacity. Pass them as keyword arguments:

```python theme={null}
bundle = client.cell_measurement.create(
    cell_instance.id,
    measurement_data,
    validate_strict=True,
    rated_capacity=0.002,           # A.h — nominal capacity of the cell
    voltage_window=(2.5, 4.2),      # (V_min, V_max) from the cell spec
)
```

<Tip>
  If you already have the cell spec object, you can read these values
  directly from it:

  ```python theme={null}
  ratings = cell_spec.ratings
  rated_capacity = ratings["capacity"]["value"]
  voltage_window = (
      ratings["voltage_min"]["value"],
      ratings["voltage_max"]["value"],
  )
  ```
</Tip>

Both arguments are optional. Omitting `voltage_window` skips only the
voltage-continuity check; omitting `rated_capacity` skips only the
consecutive-full-step and per-step capacity checks. All other strict-mode
checks still run.

<Note>
  The consecutive-full-step and per-step capacity checks also require a
  pre-computed step summary under the `steps` key of `measurement_detail`.
  If you do not provide one, Ionworks Studio generates step summaries
  server-side after upload, but these two client-side checks are skipped.
</Note>

`create_or_get` accepts the same `rated_capacity` and `voltage_window`
arguments and forwards them to `create`.

### Relaxing a single strict check

If a specific strict check is a known false positive for your dataset — for
example, a legitimate multi-day rest period that exceeds the 5-hour time-gap
threshold — relax **only that check** with `skip_checks` rather than turning
strict mode off entirely. This keeps every other guardrail in place
(least-privilege validation).

```python theme={null}
bundle = client.cell_measurement.create(
    cell_instance.id,
    measurement_data,
    validate_strict=True,
    skip_checks={"time_gaps"},  # everything else still enforced
)
```

Valid names (also exposed as `ionworks.validators.STRICT_CHECK_NAMES`):

* `minimum_points_per_step`
* `cycle_constant_within_step`
* `time_gaps`
* `voltage_continuity`
* `consecutive_same_direction_full_steps`
* `step_capacity_within_rated`
* `capacity_energy_from_current_power`

Unknown names raise `ValueError`. Avoid disabling strict mode wholesale unless
you have a documented reason that applies to multiple checks at once.

### EIS measurement validation

Cycling checks don't apply to
[electrochemical impedance spectroscopy (EIS)](/data/format#eis-and-impedance-data)
data — an EIS spectrum has no `Time [s]` or `Step count`, just impedance
samples across frequency. Tell the client that a measurement is EIS by adding
a `data_type` hint to the measurement dict, and the validator switches to an
EIS-specific set of checks.

```python theme={null}
measurement_data = {
    "measurement": {
        "name": "EIS at 25°C, 50% SOC",
        "data_type": "eis",
        "protocol": {
            "ambient_temperature_degc": 25,
        },
    },
    "time_series": eis_df,  # columns: Frequency [Hz], Z_Re [Ohm], Z_Im [Ohm]
}

bundle = client.cell_measurement.create(
    cell_instance.id,
    measurement_data,
    validate_strict=True,
    rated_capacity=0.002,  # A.h — enables the impedance-magnitude check
)
```

The following checks run:

* **Structural columns** (always on) — the frame must contain
  `Frequency [Hz]`, `Z_Re [Ohm]`, and `Z_Im [Ohm]`.
* **`eis_zim_sign`** (strict-only) — flags a spectrum whose capacitive band
  is predominantly positive. The Ionworks convention is that `Z_Im [Ohm]`
  is **negative** across the capacitive arc (see
  [EIS and impedance data](/data/format#eis-and-impedance-data)); the fix
  hint is `Z_Im = -Z_Im`.
* **`eis_impedance_magnitude`** (strict-only) — catches order-of-magnitude
  unit errors (Ω vs. mΩ vs. kΩ) by comparing `min(Z_Re)` against a wide
  100× band around a capacity-derived estimate. Requires `rated_capacity`;
  skipped if not supplied.

Both heuristic checks fire only on order-of-magnitude issues — a fully
flipped sign, or a 1000× unit error — so a plausible spectrum is never
touched. Skip an individual check the same way as any other strict check:

```python theme={null}
bundle = client.cell_measurement.create(
    cell_instance.id,
    measurement_data,
    validate_strict=True,
    skip_checks={"eis_zim_sign"},
)
```

## Idempotent uploads with `create_or_get`

All three create methods have `create_or_get` variants that make upload
scripts safely re-runnable — if a resource with the same name already exists,
the client fetches and returns it instead of raising an error.

```python theme={null}
# cell_spec: data only
spec = client.cell_spec.create_or_get(data)

# cell_instance: requires cell_spec_id
instance = client.cell_instance.create_or_get(cell_spec_id, data)

# cell_measurement: requires cell_instance_id
measurement = client.cell_measurement.create_or_get(cell_instance_id, data)
```

## Duplicate handling

When you call `create()` (not `create_or_get`) and a resource with the same
name already exists, the API returns a `409 Conflict` with the existing
resource's ID in the `detail` field:

```json theme={null}
{
  "error_code": "CONFLICT",
  "message": "Cell specification with this name already exists",
  "detail": {
    "resource_type": "cell_specification",
    "resource_name": "My Cell Spec",
    "existing_id": "abc-123"
  }
}
```

The `existing_id` lets you fetch the existing record without a separate lookup
if you want to implement your own create-or-get logic.

## Inline time series size limit

Time series passed inline (e.g. inside a pipeline configuration) are capped
at **1,000 rows** — larger datasets must be uploaded as measurements first.
See [reading data — inline size limit](/data/reading#inline-time-series-size-limit)
for the error class, workaround, and `db:<measurement_id>` reference form.

## Automatic payload compression

The Python client automatically compresses request payloads larger than 512 KB
using gzip before sending them to the API. This happens transparently — no
configuration is needed. For large serialized models or datasets, this can
reduce upload sizes significantly.

## Troubleshooting

### Large time gap between consecutive rows

**Problem:** When uploading with `validate_strict=True`, you receive an error
like:

> Time gap of 6.12 h between rows 4201 and 4202 exceeds the maximum allowed
> gap of 5.0 h.

**Solution:** A gap longer than 5 hours between two consecutive time samples
almost always means rows were dropped during processing — for example, a long
rest period was recorded as elapsed time in the file but the intermediate rows
were stripped. The capacity integral then counts current across the missing
interval and reports inflated values.

Re-read the raw cycler file without filtering and confirm the time axis is
continuous. If your workflow intentionally decimates the data, downsample
uniformly rather than removing whole sections, or split the recording into
separate measurements at the gap.

### Voltage continuity check failed

**Problem:** When uploading with `validate_strict=True` and a `voltage_window`,
you receive an error like:

> Voltage continuity check failed: 318/3599 (8.8%) of consecutive row pairs
> have a voltage jump greater than 80% of the rated voltage window.

**Solution:** Ionworks looks at the absolute voltage change between every pair
of consecutive rows. If more than 5 % of pairs exceed 80 % of the rated voltage
window (`V_max - V_min`), the time series is almost certainly out of
chronological order — typically because of a grouping operation like
`partition_by("Cycle_raw")` that interleaves pulse and rest rows from
different cycles.

Sort your DataFrame by `Time [s]` before uploading, and avoid any transform
that breaks the natural row order:

```python theme={null}
import polars as pl

time_series = time_series.sort("Time [s]")
```

### Consecutive full-capacity steps in the same direction

**Problem:** When uploading with `validate_strict=True`, a `steps` dataframe,
and a `rated_capacity`, you receive an error like:

> Consecutive full-capacity discharge steps detected: step 12 and step 15
> each delivered more than 200% of the rated capacity.

**Solution:** "Consecutive" here means the two nearest same-direction steps
in the sequence — so step 12 and step 15 are consecutive discharges even if
charge steps sit between them. A single measurement should represent one
continuous test on a single cell, so two back-to-back full-capacity
discharges (or charges) usually mean the file was assembled by concatenating
several independent experiments (for example, multiple CC discharges at
different C-rates stitched together). Split the source data back into
separate measurements before uploading.

<Note>
  If a legitimately long single experiment contains more than two full
  charges or discharges in a row — rare, but possible — drop the pre-computed
  `steps` entry from `measurement_detail` or omit `rated_capacity` so this
  specific check is skipped, and let Ionworks Studio regenerate step
  summaries server-side.
</Note>

### Step capacity exceeds rated capacity

**Problem:** When uploading with `validate_strict=True`, a `steps` dataframe,
and a `rated_capacity`, you see a `UserWarning` like:

> 3 step(s) exceed 500% of the rated capacity. First: step 47 has
> 'Discharge capacity \[A.h]' = 0.031 A.h.

**Solution:** This is a soft warning — the upload still proceeds — but it
signals that something is probably wrong with your step boundaries or that
the capacity integral was inflated across an unrecorded time gap. Inspect the
flagged step, verify it represents a single charge or discharge, and re-check
the `Step count` column for gaps. Fixing the underlying time-gap or step
boundary issue usually clears the warning.

### Capacity or energy column disagrees with the current/power integral

**Problem:** When uploading with `validate_strict=True`, you receive an error
like:

> Column 'Discharge capacity \[A.h]' disagrees with the running integral of
> 'max(I, 0)' over time by up to 47.3% (exceeds 10% tolerance). Worst row
> 8421: reported = 0.0019 A.h, integrated = 0.0010 A.h.

This check compares each reported cumulative column against the trapezoidal
integral of current (capacity) or power (energy) over time, with a per-step
reset that mirrors how the platform reports cumulative columns. With the
Ionworks sign convention (positive current = discharge):

* `Discharge capacity [A.h]` ≈ `∫ max(I, 0) dt / 3600`
* `Charge capacity [A.h]` ≈ `∫ max(-I, 0) dt / 3600`
* `Discharge energy [W.h]` ≈ `∫ max(P, 0) dt / 3600`
* `Charge energy [W.h]` ≈ `∫ max(-P, 0) dt / 3600`

Power is taken from `Power [W]` if present, otherwise computed as
`Voltage [V] * Current [A]`. The corresponding issue codes are
`DISCHARGE_CAPACITY_INTEGRAL_MISMATCH`, `CHARGE_CAPACITY_INTEGRAL_MISMATCH`,
`DISCHARGE_ENERGY_INTEGRAL_MISMATCH`, and `CHARGE_ENERGY_INTEGRAL_MISMATCH`.
Each issue's `payload` carries the worst-row index, the reported and
integrated values at that row, the max cumulative scale, and the relative
error.

**Solution:** The mismatch usually traces back to swapped `Discharge` / `Charge`
labels, a wrong current sign convention, or unreliable source columns. All three
are covered by the transform recipes in
[preparing data — swapped charge and discharge columns](/data/preparing-data#swapped-charge-and-discharge-cumulative-columns)
and the [sign-convention troubleshooting](/data/preparing-data#incorrect-current-sign-convention)
above it — fix the data there and re-upload.

If the discrepancy is instead a known artefact of your dataset and you have
verified it does not affect downstream analyses, relax just this check with
`skip_checks={"capacity_energy_from_current_power"}`.

## Next steps

<CardGroup cols={2}>
  <Card title="Measurements" icon="flask" href="/data/measurements">
    The three measurement types — time series, properties, file — in detail.
  </Card>

  <Card title="Reading data" icon="download" href="/data/reading">
    List, filter, paginate, and retrieve measurement data.
  </Card>

  <Card title="Preparing data" icon="file-import" href="/data/preparing-data">
    Read cycler files into the Ionworks format before uploading.
  </Card>

  <Card title="Data format" icon="file-code" href="/data/format">
    Recognized columns, quantity format, and sign conventions.
  </Card>
</CardGroup>
