Skip to main content
Before uploading, raw data from your cycler needs to be converted into the Ionworks data format. The ionworksdata library reads files from common battery cyclers, auto-detects formats, and normalizes units, timestamps, and column names.
pip install ionworksdata

Supported cyclers

ionworksdata auto-detects the file format when possible and produces a polars DataFrame with the standard columns.
CyclerFile types
Arbin.csv, .xlsx, .res
BaSyTec.csv
BioLogic.mpt, .mpr, .txt
Maccor.txt, .csv, .xls, .xlsx
Neware.csv, .xls, .xlsx (multi-sheet supported; automatic Latin-1 fallback for CSVs)
Novonix.csv
Repower.csv
Generic CSV.csv (any CSV with recognized column headers or custom mappings)
Generic Parquet.parquet (any parquet file with recognized column headers or custom mappings)
BDF (Battery Data Format).bdf, .bdf.gz, .bdf.parquet, .csv
import ionworksdata as iwd

df = iwd.read.time_series("my_test.mpt", "biologic")

Custom column mappings

If your CSV uses non-standard column names, map them to the standard names with extra_column_mappings. The reader skips auto-detection for any column you explicitly map, so values are used as-is without rescaling.
import ionworksdata as iwd

df = iwd.read.time_series(
    "my_data.csv",
    "csv",
    extra_column_mappings={
        "vCell": "Voltage [V]",
        "iCell": "Current [A]",
        "t": "Time [s]",
        "Tcell": "Temperature [degC]",
    },
)
Partial mappings work too — any standard column you don’t map is still auto-detected:
df = iwd.read.time_series(
    "my_data.csv",
    "csv",
    extra_column_mappings={"vCell": "Voltage [V]"},
)
Use extra_column_mappings when your CSV comes from a custom test setup or proprietary cycler. Mapped values are preserved exactly — no unit conversion or scaling is applied. Ensure your data is already in the expected standard units (V, A, s, °C) before using this parameter.

Generic parquet files

For parquet files that don’t follow the BDF spec, use the generic parquet reader. It mirrors the CSV reader’s column-detection strategy — recognizing common aliases for voltage, current, time, and temperature — but skips text-parsing concerns since parquet is strongly typed and has unambiguous column names. No separator, encoding, or quote handling is needed. Use it when you have cycler data already exported to parquet (for example, from an internal pipeline or another tool) and want it normalized into the Ionworks data format. Any .parquet file (except .bdf.parquet) is auto-detected; you can also select the reader explicitly:
import ionworksdata as iwd

# Auto-detected by extension
df = iwd.read.time_series("cell.parquet")

# Or call the reader directly
df = iwd.read.parquet("cell.parquet")
If the file uses non-standard column names, pass extra_column_mappings just like with the CSV reader:
df = iwd.read.parquet(
    "cell.parquet",
    extra_column_mappings={
        "vCell": "Voltage [V]",
        "iCell": "Current [A]",
        "t": "Time [s]",
    },
)

<Note>
  BDF parquet files (`.bdf.parquet`) are still routed to the BDF reader —
  the generic parquet reader is only used as a fallback for `.parquet`
  files that aren't BDF.
</Note>

## Battery Data Format (BDF)

`ionworksdata` can read and write files in the
[Battery Data Format (BDF)](https://battery-data-alliance.github.io/battery-data-format/)
defined by the Battery Data Alliance. CSV, gzipped CSV, and parquet variants
are all supported. Files are auto-detected by their header or extension
(`.bdf`, `.bdf.gz`, `.bdf.parquet`).

```python
import ionworksdata as iwd

# Read
df = iwd.read.bdf("cell.bdf")
df = iwd.read.time_series("cell.bdf.parquet")  # via the generic entrypoint

# Write
iwd.write.bdf(df, "out.bdf")                  # CSV with preferred labels
iwd.write.bdf(df, "out.bdf.gz")               # gzipped CSV
iwd.write.bdf(df, "out.bdf.parquet")          # parquet
iwd.write.bdf(df, "out.bdf", use_machine_readable_names=True)
BDF does not mandate a current sign convention. The reader normalises current to the Ionworks convention (positive = discharge) on load, so third-party BDF files that follow the opposite IEC convention are flipped automatically. The writer emits whatever convention is in the input DataFrame — pass the data through transform.set_positive_current_for_discharge first if you need to guarantee discharge-positive output.

EIS and impedance data

InstrumentFile types
BioLogic.mpt, .mpr, .txt (files containing impedance columns)
Gamry.dta (ZCURVE table)
Impedance data is read into columns Frequency [Hz], Z_Re [Ohm], Z_Im [Ohm], Z_Mod [Ohm], and Z_Phase [deg].
import ionworksdata as iwd

df = iwd.read.gamry("eis_measurement.dta")
See the data format page for the full column spec and sign convention.

Troubleshooting

Incorrect current sign convention

Problem: When uploading measurement data, you receive an error like:
Current sign convention error: positive current appears to be charge, not discharge.
Solution: Ionworks expects positive current = discharge and negative current = charge. If your cycler uses the opposite convention, convert the data before uploading:
import ionworksdata as iwd

data = iwd.transform.set_positive_current_for_discharge(data)

Ambiguous current sign convention

Problem: When uploading measurement data, you receive an error like:
Current sign convention error: the sign convention is ambiguous.
This happens when all current values have the same sign, so the validator cannot determine whether positive means charge or discharge. Solution: Use the same transform — it uses voltage-response analysis (fitting an OCV-R equivalent circuit model under both sign conventions) to infer charge vs. discharge direction even when all currents share the same sign:
import ionworksdata as iwd

data = iwd.transform.set_positive_current_for_discharge(data)
If the automatic approach does not work for your data (for example, flat voltage profiles), you can manually apply a sign based on the step type column from your cycler:
import polars as pl

# Replace "Step type" and "charge" with your cycler's column name
# and step-type label (e.g. "CC_Charge", "Charge", "C", etc.)
data = data.with_columns(
    pl.when(pl.col("Step type") == "charge")
    .then(-pl.col("Current [A]").abs())
    .otherwise(pl.col("Current [A]").abs())
    .alias("Current [A]")
)

Unsigned (magnitude-only) current

Problem: When uploading measurement data, you receive an error like:
Current sign convention error: the current appears to be unsigned (magnitude-only) but contains both charge and discharge steps. Sign it using the cycler mode column via ionworksdata.transform.set_positive_current_for_discharge(data) before validation.
This fires when every non-rest current sample is positive yet the data clearly contains both charge and discharge steps — a fingerprint of a cycler that records current as a magnitude and encodes direction in a separate mode column rather than in the sign of Current [A]. The issue is distinct from ambiguous sign convention because it has a concrete, deterministic fix: re-sign the current from the cycler’s own charge/discharge labels. Solution: Run set_positive_current_for_discharge. When it detects an all-positive non-rest current alongside both charge and discharge steps, it flips the sign of charge-step samples using the cycler mode column instead of falling back to the voltage-response heuristic:
import ionworksdata as iwd

data = iwd.transform.set_positive_current_for_discharge(data)
The issue code is CURRENT_SIGN_UNSIGNED — branch on it explicitly if you want to apply the auto-fix without prompting:
from ionworks import IssueCode, MeasurementValidationError
import ionworksdata as iwd

try:
    client.cell_measurement.create(cell_instance.id, data)
except MeasurementValidationError as e:
    if e.has_code(IssueCode.CURRENT_SIGN_UNSIGNED):
        data = iwd.transform.set_positive_current_for_discharge(data)
        client.cell_measurement.create(cell_instance.id, data)
    else:
        raise

Swapped charge and discharge cumulative columns

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 96.4% (exceeds 10% tolerance).
…and the reported Discharge capacity [A.h] looks like it tracks the charge half-wave, with the same flipped behaviour on Charge capacity [A.h]. This happens with some half-cell exports and cycler configurations that label the two cumulative columns inversely. Solution: Use fix_swapped_charge_discharge_columns to compare each column against the trapezoidal integral of current (and power, for energy) and rename the pair when the swapped assignment is within tolerance and the as-is assignment is not.
import ionworksdata as iwd

# Required prerequisite — without a known sign convention, "Discharge" vs
# "Charge" has no ground-truth meaning to compare against.
data = iwd.transform.set_positive_current_for_discharge(data)

# Inspects "Discharge/Charge capacity [A.h]" and, when present,
# "Discharge/Charge energy [W.h]". Returns `data` unchanged when no swap
# is warranted.
data = iwd.transform.fix_swapped_charge_discharge_columns(data)
The transform requires Time [s], Step count, and Current [A] columns, and uses Power [W] (or Voltage [V] * Current [A] when Power [W] is absent) for the energy pair. The default tolerance is 10 % relative error; pass a different value if your data needs a tighter or looser threshold:
data = iwd.transform.fix_swapped_charge_discharge_columns(data, tolerance=0.05)
The function refuses to swap labels when positive current does not correspond to discharge — without a known sign convention there is no way to tell which column is which, and swapping would mask a real sign- convention bug. Run set_positive_current_for_discharge first.

Time not cumulative

Problem: Time resets to 0 for each cycle. Solution: Track a cumulative time offset:
cumulative_time_offset = 0.0
for cycle_num, cycle_df in df.group_by("Cycle from cycler"):
    cycle_df = cycle_df.with_columns(
        (pl.col("Time [s]") + cumulative_time_offset).alias("Time [s]")
    )
    cumulative_time_offset = cycle_df["Time [s]"].max()

Step count not cumulative

Problem: Step count resets for each cycle. Solution: Track a step count offset across cycles:
step_offset = 0
for cycle_num, cycle_df in df.group_by("Cycle count"):
    cycle_df = cycle_df.with_columns(
        (pl.col("Step from cycler") + step_offset).alias("Step count")
    )
    step_offset = cycle_df["Step count"].max() + 1

Missing capacity columns

Problem: Capacity calculation fails. Solution: Ensure you have Time [s], Current [A], and Voltage [V] columns before calculating capacity.

Non-UTF-8 CSV files (e.g. Neware)

Problem: Reading a Neware CSV file fails with an encoding error. Solution: The Neware reader automatically falls back to Latin-1 if UTF-8 decoding fails, so no action is needed in most cases:
import ionworksdata as iwd

df = iwd.read.time_series("neware_data.csv", "neware")
To explicitly control the encoding:
df = iwd.read.time_series(
    "neware_data.csv",
    "neware",
    options={"file_encoding": "latin-1"},
)

Next steps

Data format

Full reference for recognized columns, units, and sign conventions.

Uploading data

Upload prepared data as cell specs, instances, and measurements.

ionworksdata API reference

Complete reference for read, write, transform, steps, and load.

ionworksdata on GitHub

Report issues or browse the source.