Arbin makes good cyclers and a file format that surprises everyone: a .res file is not a text export or a proprietary binary — it is a Microsoft Access database. Once you know that, the vendor software stops being mandatory. But the path from .res to a defensible cycle-life curve still has three traps, and all three produce numbers that look plausible.
This is the full pipeline: inspect the schema, load the tables, join the auxiliary channels, rebuild the per-cycle table, and check the result before anyone plots it. Everything here is runnable with open-source tooling.
Why a database changes the workflow
Most cycler formats are one flat table with a header. Arbin splits the data across tables:
Channel_Normal_Table— the main measurement table (voltage, current, capacity, energy, step and cycle indices).Channel_Normal_Table_W1,W2, … — auxiliary channel records (temperatures, reference electrodes), joined back viaTest_IDandData_Point.Channel_Normal_Statistical— per-step aggregates (the "Statistics" view in MITS).
The table names are stable. The columns are not: firmware generations rename and reorder fields, and a script that matches on exact column names breaks the first time a lab gets a new instrument. So step one is never "load the data" — it is "look at the schema and assert it".
Step 1 — list the tables before writing any query
On Linux or macOS, mdbtools does everything the Access GUI would:
# What tables exist in this file?
mdb-tables -1 cell_042.res
# Typical output:
# Channel_Normal_Statistical
# Channel_Normal_Table
# Channel_Normal_Table_W1
# Dump the main table to CSV
mdb-export cell_042.res Channel_Normal_Table > channel_normal.csvOn Windows, use the Access ODBC driver through pandas — no GUI required:
# pip install pandas_access
from pandas_access import read_table
main = read_table("cell_042.res", "Channel_Normal_Table")
aux1 = read_table("cell_042.res", "Channel_Normal_Table_W1")
# Assert BEFORE you trust: print the actual schema, not the one you remember.
print(main.columns.tolist())
print(len(main), "rows,", main["Channel_Index"].nunique(), "channels")If Channel_Index shows more channels than you expected, you are about to interleave cells — see failure mode 2 below.
Step 2 — join the auxiliary channels
Secondary channels are useless on their own; they only mean something aligned to the main record. The join key is (Test_ID, Data_Point):
merged = main.merge(
aux1,
on=["Test_ID", "Data_Point"],
how="left",
suffixes=("", "_w1"),
)
unmatched = merged["Temperature_w1"].isna().sum() if "Temperature_w1" in merged else None
print("unmatched aux rows:", unmatched)If a large fraction of auxiliary rows do not match, the two tables are not from the same test segment — stop and check before continuing. A silent NaN here becomes a silent gap in your temperature plot later.
Step 3 — rebuild the per-cycle table like an auditor
The reported capacity column is convenient and sometimes wrong. Recompute it from the current integral and compare:
import numpy as np
import pandas as pd
# One channel only. Mixing channels here is the most common silent error.
ch = merged[merged["Channel_Index"] == 1].copy()
ch = ch.sort_values("Data_Point")
dis = ch[ch["Current"] < 0].copy() # discharge convention: verify sign for your file
dis["dt_s"] = dis.groupby("Cycle_Index")["Test_Time"].diff().fillna(0)
# dQ = ∫ I dt, in Ah (current in A, time in s)
dis["dQ_Ah"] = (dis["Current"].abs() * dis["dt_s"]).groupby(dis["Cycle_Index"]).cumsum() / 3600
computed = dis.groupby("Cycle_Index")["dQ_Ah"].max()
reported = dis.groupby("Cycle_Index")["Discharge_Capacity"].max()
check = pd.DataFrame({"computed_Ah": computed, "reported_Ah": reported})
check["rel_diff"] = (check["computed_Ah"] - check["reported_Ah"]).abs() / check["reported_Ah"]
print(check["rel_diff"].describe())
# If p95 rel_diff > a few percent, the cycle boundaries are wrong.
# Do NOT proceed to fitting a degradation model on this table.That assertion is the whole game. If the recomputed capacity disagrees with the reported one, the disagreement is either a unit issue (mAh vs Ah), a boundary issue (rest steps inflating cycles), or a current-sign convention mismatch. All three are fixable; none are fixable *after* you have fit a model.
For the modelling side — retention curves, RUL with intervals, degradation-mechanism analysis — the cleaned cycle table is the input, not the raw record stream.
The three failure modes
1. Test names live in file names, not columns. Arbin does not reliably record the cell identity inside the database. A folder of cell_042.res files is traceable only by naming discipline. Copy the file name into a test_id column at load time and keep it through every join and export — this is the field that will save you when two channels turn out to be the same cell on different fixtures.
2. Multiple channels interleaved per file. One .res file can hold several channels. Filter by Channel_Index before any grouping operation. groupby("Cycle_Index") across interleaved channels produces cycles that contain two cells' data at once.
3. Cycle_Index inflation. Rests and CV holds can increment or reset cycle counters depending on the test schedule. Never trust the raw cycle count; rebuild it from the step table using a documented convention (e.g. a new cycle starts at each discharge step), and write that convention down next to the plot.
If you would rather not maintain Access drivers
The free parser at /tools/arbin reads a .res (up to 2 MB) in memory and shows the parsed series, the per-step table and derived analytics — no account, nothing stored. For full-size files and multi-file campaigns, the ingestion path in a free Matflow account keeps the original .res beside the parsed rows as provenance, so the firmware-variant question ("what did we actually load?") is answerable months later. The same workflow exists for Neware .ndax and Bio-Logic .mpr files.
If the battery workflow continues, see the Neware post for what to do with the cycle table next: fit the three degradation modes (SEI growth, cycling-induced fade, lithium-inventory loss) and check the retained-capacity interval before quoting a prognosis.
Honest limits
- This pipeline assumes the Access-database flavour of
.res. Older or exported variants exist; verify the table list first, as shown in step 1. - Column names in the code above are the common MITS Pro layout. Your firmware may differ — the assertions in step 1 and step 3 are what make the script portable, not the column names.
- Recomputed capacity agrees with reported capacity only when the step segmentation is right. The check flags disagreement; it does not decide which side is wrong.
- Nothing here validates the measurement itself (reference drift, thermocouple placement, fixture resistance). Provenance and physics live upstream of parsing.
The same discipline — parse, assert, review, then model — is what the ingestion docs describe for every instrument family; the battery-specific details are in /docs/instruments/overview.