Skip to content

Cell Incoming Inspection

Incoming inspection of a 21700 cell: OCV against the shipping window with a sorting bin, 1 kHz ACIR grade, 10 A pulse DCIR, self-discharge current in an hour.

TofuPilotIncoming InspectionPythonTofuPilot FrameworkGitHub
Cell Incoming Inspection

Introduction

Cell Incoming Inspection Overview

A pack is only as good as its worst cell, and the cell vendor's certificate covers the cells as they left the vendor, not as they arrived. Between the two sit weeks in a container, a temperature history nobody logged, and the small fraction of cells with a latent internal short that no factory screen catches on day one. Incoming inspection is where a pack maker measures each cell before committing it to a module: its open-circuit voltage against the shipping window, its impedance, and, on the lines that take self-discharge seriously, the current a cell needs to stay at its own voltage. The result is a grade per cell and a bin that pack matching draws from.

A single 21700 cell standing in a fixture block, a dark probe head above it with two gold spring probes on the positive terminal.

One cell under the four-wire probe head: two spring probes on the positive terminal, two under the can, the impedance meter and the self-discharge analyzer both reading through the same contacts.

The numbers on the datasheet are the ones to build on, and there are two of them for resistance. A Samsung INR21700-33J is specified at 29 ± 5 mΩ AC at 1 kHz and 40 ± 10 mΩ DC at 50 % SOC, on the same cell; a Molicel P42A at 10 mΩ AC and 16 mΩ DC. The ratio is about 1.5 and it is not a tolerance: the 1 kHz value is the ohmic part, the DC value adds charge transfer and diffusion, and it is the DC value that predicts sag under load (Hioki's application material on lithium-ion internal resistance covers the distinction). The two get measured with different instruments, in different times, and never share a limit.

Test Purpose

The procedure records one identity and health fingerprint per cell:

  • Contact, mass and surface temperature before any electrical reading
  • Open-circuit voltage against the vendor's shipping window, and the 10 mV bin the cell sorts into
  • 1 kHz AC impedance, four-terminal, in 18 ms, with a grade letter
  • DC resistance from a 10 A pulse, split into the ohmic step and the polarization droop
  • Self-discharge current by the potentiostatic method, settled over an hour, with the equivalent OCV drop rate for comparison with aging-based screens

Terminal voltage over a 10 A, 1 s discharge pulse: a 148 mV instantaneous step at the pulse edge, an exponential droop to the end of the pulse, and recovery afterwards; the current as a dotted step on the right axis.

The mock cell's pulse: 14.8 mΩ of ohmic step at the edge, 20.0 mΩ total at the end of the second, against 13.6 mΩ at 1 kHz on the same cell and a 32 mΩ DC limit. The three numbers describe one cell and each says something the others do not.

The framework mechanics on show are then: error: retry on a phase whose instrument raises a contact error, a string in validator on the grade, a multi-dimensional waveform with two derived aggregations (one limited, one recorded), a time-scaled hour-long capture with a settled-value aggregation, a station-scoped plug, and unit metadata that pack matching reads later.

Equipment & Setup

To run this inspection at goods-in, the following are required:

  • A battery impedance meter with a four-terminal 1 kHz AC method and a voltage function, fast enough for a tray
  • A DC load in transient mode for the 10 A pulse, with the meter's voltage function logging the terminal voltage
  • A self-discharge analyzer that holds the cell at its own OCV and reads the current it supplies at sub-microamp resolution
  • A probe fixture with two spring probes per terminal, a load cell under the cell and an IR sensor on the head
  • The Device Under Test (DUT): one 21700 cell from the incoming tray, at the ambient of the room for at least a few hours
  • A TofuPilot Framework procedure to sequence the readings, grade the cell and validate the limits
  • The TofuPilot Dashboard to keep the per-cell record that pack matching and, from 2027, the EU battery passport's genealogy draw on

Hardware Components

Impedance Meter

The Hioki BT3562 is the production reference for the 1 kHz method: 1 kHz ± 0.2 Hz, ±0.5 % of reading, 0.1 µΩ resolution, and resistance and voltage together in 18 ms on a four-terminal probe. For an engineering audit the BT4560 sweeps 0.01 Hz to 1050 Hz and separates electrolyte, charge-transfer and diffusion resistance, in minutes per cell; that is a sample-audit instrument, not a line instrument, and a template that assumed full impedance spectroscopy at line rate would not survive contact with a tray of 400 cells.

Cell incoming inspection station: a tray of 21700 cells on the left with a two-probe head on an arm over one cell, cabled to a battery impedance meter on the right.

A single-tray station: the probe head indexes over the tray, the meter reads each cell through it, the self-discharge analyzer holds the cell that the head is parked on.

Self-Discharge Analyzer

The classic screen for a latent short is a voltage drop over time: charge the cell to a set SOC, rest it, measure the OCV, store it for two to four weeks, measure again. The Keysight BT2152B replaces the weeks with about an hour: it holds the cell at its own open-circuit voltage to ±3 µV over 24 hours and reads the current it has to supply on a ±10 mA range at 250 nA resolution, on up to 32 channels. Once the cell's internal charge redistribution has settled, the current the analyzer supplies is the current the cell is losing, and a micro-short reads hundreds of microamps where a healthy cell reads about ten.

DC Pulse Load

Any load with a transient mode that reaches 10 A in under a millisecond: a Chroma 63600 or a Kikusui PLZ-4W at 10 µs rise. The voltage waveform comes from the meter's V function or a DAQ at 1 kHz; the pulse is short enough not to move the SOC.

Where the Limits Come From

The OCV window is the vendor's: 3.43 to 3.63 V for this cell class, the roughly 30 % state of charge the IATA transport rule requires for air shipment. The 1 kHz impedance window is the datasheet's. The DC limit sits at 1.6 times the top of the AC window. The self-discharge gate is the pack maker's own: peer-reviewed measurements on commercial NCA and silicon-graphite cells at 50 % SOC and 25 °C put a healthy cell around 10 µA and 1 to 2 mV of OCV drop over 28 days, about 0.05 mV/day; the "2 mV/day" figure that circulates as a K-value limit is forty times that and passes almost anything. A 30 µA gate is three times a healthy cell and an order of magnitude under a micro-short. The grading bins (10 mV of OCV, three impedance grades) are what pack matching needs and nothing a standard specifies.

Test Procedure

Overview

The procedure maps the inspection onto the framework's three stages. Contact and identity live in setup: so no electrical number is recorded on a cold cell or through a lifted probe. Lifting the probes lives in teardown: so the tray can index whatever happened.

  1. Setup: probe path, mass, surface temperature.
  2. Main: OCV against the shipping window, 10 mV bin.
  3. Main: 1 kHz AC impedance, grade, retried on a contact error.
  4. Main: 10 A pulse, ohmic step and DC resistance.
  5. Main: self-discharge current, 60 minutes, settled value.
  6. Teardown: probes up.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot as the cell's record.

Why TofuPilot Framework?

TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:

  • Automatic Python environment management (via uv)
  • Operator UI (no frontend code needed)
  • Measurement validation and live charts
  • Process isolation between phases and equipment plugs

Project Structure

procedure.yaml
phases
contact_check.py
ocv_grading.py
acir_1khz.py
dcir_pulse.py
self_discharge.py
release.py
plugs
cell_station.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The CellStation plug is a mock of the meter, the load, the analyzer, the scale and the IR sensor together, synthesizing one healthy cell whose first impedance read bounces on the probes, so the retry runs every time and the procedure runs end-to-end without a station or a cell connected. The self-discharge capture is time-scaled: the mock returns the hour in one call.

tofupilot run .

For CI or bench automation, the same run executes headless:

tofupilot run . --no-tui --no-kiosk --json

The Procedure File

procedure.yaml declares the unit (the cell's laser-marked QR code), the station plug at station scope, and the three stages with every measurement and limit:

procedure.yaml
name: Cell Incoming Inspectionversion: 0.1.0description: Incoming inspection of a 21700 NMC cell. Contact and identity checks, open-circuit voltage against the vendor's shipping window with a 10 mV sorting bin, 1 kHz AC impedance with a grade, 10 A DC pulse resistance split into ohmic and polarization parts, self-discharge current by the potentiostatic method in an hour.unit:  auto_identify: true  serial_number:    description: "Scan the cell's laser-marked QR code"    placeholder: "C21700-0000000000"    pattern: "^C21700-\\d{10}$"    default_value: "C21700-2609104417"  part_number:    default_value: "INR21700-50E"  batch_number:    default_value: "LOT-8842"plugs:  - name: Cell Station    description: "AC impedance meter, DC pulse load, self-discharge analyzer, scale and IR sensor on one probe fixture (mock, station scope)"    python: plugs.cell_station:CellStation    key: station    scope: stationsetup:  - name: Contact Check    key: contact_check    python: phases.contact_check    measurements:      - name: Probe Path        key: probe_path_ohm        unit: Ω        validators:          - {operator: "<=", expected_value: 0.5}      - name: Mass        key: mass_g        unit: g        description: A 21700 NMC cell of this capacity weighs 67 to 70 g; lighter is a different part or an empty can.        validators:          - {operator: ">=", expected_value: 67.0}          - {operator: "<=", expected_value: 70.0}      - name: Surface Temperature        key: surface_temp_c        unit: °C        description: OCV and impedance are both temperature dependent; a cell straight from a cold truck is measured later, not corrected.        validators:          - {operator: ">=", expected_value: 20.0}          - {operator: "<=", expected_value: 28.0}main:  - name: OCV Grading    key: ocv_grading    python: phases.ocv_grading    measurements:      - name: OCV        key: ocv_v        unit: V        description: Vendor shipping window 3.43 to 3.63 V (about 30 % SOC per the IATA transport rule); outside it the cell was mishandled or is not what the label says.        validators:          - {operator: ">=", expected_value: 3.43}          - {operator: "<=", expected_value: 3.63}      - name: OCV Bin        key: ocv_bin        description: 10 mV bin from the window floor, 0 to 19; cells in one pack come from one bin.        validators:          - {operator: ">=", expected_value: 0}          - {operator: "<=", expected_value: 19}  - name: ACIR 1kHz    key: acir_1khz    python: phases.acir_1khz    depends_on: [ocv_grading]    then:      error: retry    measurements:      - name: ACIR        key: acir_mohm        unit: mΩ        description: 1 kHz four-terminal impedance; the datasheet number for this class of cell, 10 to 20 mOhm.        validators:          - {operator: ">=", expected_value: 10.0}          - {operator: "<=", expected_value: 20.0}      - name: ACIR Grade        key: acir_grade        description: A below 15 mOhm, B below 18, C below 20; a pack is built from one grade.        validators:          - {operator: in, expected_value: [A, B, C]}  - name: DCIR Pulse    key: dcir_pulse    python: phases.dcir_pulse    depends_on: [acir_1khz]    measurements:      - name: Pulse        key: pulse        title: 10 A discharge pulse, terminal voltage and current        x_axis:          legend: Time          unit: s        y_axis:          - legend: Voltage            key: voltage            unit: V            aggregations:              - type: dcir_mohm                unit: mΩ                validators:                  - {operator: "<=", expected_value: 32.0}              - type: ohmic_mohm                unit: mΩ          - legend: Current            key: current            unit: A  - name: Self Discharge    key: self_discharge    python: phases.self_discharge    depends_on: [dcir_pulse]    timeout: 90m    measurements:      - name: Self-Discharge Current        key: sd_current        title: Potentiostatic self-discharge current over 60 minutes        x_axis:          legend: Time          unit: min        y_axis:          - legend: Current            key: current            unit: µA            aggregations:              - type: settled_ua                unit: µA                validators:                  - {operator: "<=", expected_value: 30.0}      - name: Equivalent OCV Drop        key: equivalent_mv_per_day        unit: mV/day        description: The settled current expressed as an OCV drop rate for comparison with aging-based screens; recorded, not limited.teardown:  - name: Release    key: release    python: phases.release

Framework features to notice:

  1. then: error: retry. The impedance phase declares what to do when its plug raises: retry. The first read bounces on the probes, the meter reports a contact error, the phase runs again and passes; the report shows both attempts.
  2. String in. acir_grade validates against a list of grade letters; a cell outside every bin gets REJECT and fails with the reason.
  3. One aggregation limited, one recorded. The pulse waveform carries dcir_mohm with a limit and ohmic_mohm without; the second is there for the trend, not the verdict.
  4. Time-scaled hour. The self-discharge phase has a 90 minute timeout for the real analyzer and returns in milliseconds on the mock; the settled_ua aggregation is the mean of the last ten minutes either way.
  5. Station-scoped plug. scope: station keeps the instruments open across the tray; setup: and teardown: land and lift the probes per cell.
  6. Unit metadata for the next station. ocv_bin, acir_grade and cell_lot go onto the unit, where pack matching reads them.

Contact Check

The setup phase lands the probes, reads the four-wire path resistance, and takes the two identity readings that cost nothing and catch a wrong tray: the mass on the load cell and the can temperature on the IR sensor. The cell lot goes onto the unit metadata:

phases/contact_check.py
def contact_check(measurements, station, unit, log):    """Setup: probes down, four-wire path resistance inside the meter's    tolerance, cell mass and surface temperature as identity checks. A    cell 2 g light is a different cell; a warm cell reads a wrong OCV."""    path = station.probes_down()    measurements.probe_path_ohm = path    measurements.mass_g = station.mass_g()    measurements.surface_temp_c = station.surface_temp_c()    unit.metadata["cell_lot"] = "LOT-8842"    log.info(f"Cell {unit.serial_number}: probes at {path:.2f} ohm, {measurements.mass_g} g, {measurements.surface_temp_c} C")

OCV Grading

The meter's voltage function gives the open-circuit voltage at 0.1 mV. The phase validates it against the shipping window and computes the 10 mV bin from the window floor; the bin is what the pack line sorts on, so it goes onto the unit as well:

phases/ocv_grading.py
import mathfrom utils.recipe import OCV_BIN_MV, SHIPPING_OCV_Vdef ocv_grading(measurements, station, unit, log):    """Open-circuit voltage against the vendor's shipping window, and the    10 mV bin the cell sorts into for pack matching."""    ocv = station.ocv_v()    bin_index = int(math.floor((ocv - SHIPPING_OCV_V[0]) * 1000.0 / OCV_BIN_MV))    measurements.ocv_v = ocv    measurements.ocv_bin = bin_index    unit.metadata["ocv_bin"] = bin_index    log.info(f"OCV {ocv:.4f} V, bin {bin_index} ({SHIPPING_OCV_V[0] * 1000 + bin_index * OCV_BIN_MV:.0f} mV floor)")

ACIR 1kHz

One 18 ms read, four-terminal, at 1 kHz. The grade is looked up from the impedance bins in the recipe. When the probe bounces, the meter raises, and the then: error: retry on the phase runs it again:

phases/acir_1khz.py
from utils.recipe import ACIR_BINSdef acir_1khz(measurements, station, unit, log):    """1 kHz AC impedance, four-terminal, 18 ms. The production resistance    number: fast, repeatable, and not the same as DC resistance."""    acir = station.acir_1khz_mohm()    grade = next((g for g, (lo, hi) in ACIR_BINS.items() if lo <= acir < hi), "REJECT")    measurements.acir_mohm = acir    measurements.acir_grade = grade    unit.metadata["acir_grade"] = grade    log.info(f"ACIR 1 kHz {acir:.2f} mOhm, grade {grade}")

DCIR Pulse

A 10 A discharge for one second, the terminal voltage logged at 1 kHz. The phase takes the voltage before the edge, a few milliseconds after it, and at the end of the pulse: the first difference is the ohmic resistance, the second the total DC resistance, and both go onto the waveform as aggregations:

phases/dcir_pulse.py
import numpy as npfrom utils.recipe import DCIR_PULSE_A, DCIR_PULSE_Sdef dcir_pulse(measurements, station, log):    """10 A, 1 s discharge pulse. The instantaneous step at the pulse edge    is the ohmic part; the droop that follows is charge transfer and    diffusion. DC resistance is the total at the end of the pulse, the    number that predicts sag under load, and it runs about 1.5 times the    1 kHz value on the same cell."""    cap = station.dc_pulse(DCIR_PULSE_A, DCIR_PULSE_S)    t = np.array(cap["time_s"])    v = np.array(cap["voltage_v"])    v_before = v[(t > -0.1) & (t < 0.0)].mean()    v_step = v[(t > 0.002) & (t < 0.010)].mean()    v_end = v[(t > DCIR_PULSE_S - 0.05) & (t < DCIR_PULSE_S)].mean()    ohmic = (v_before - v_step) / DCIR_PULSE_A * 1000.0    dcir = (v_before - v_end) / DCIR_PULSE_A * 1000.0    measurements.pulse.x_axis = cap["time_s"]    measurements.pulse.y_axis.voltage = cap["voltage_v"]    measurements.pulse.y_axis.voltage.aggregations.dcir_mohm = float(dcir)    measurements.pulse.y_axis.voltage.aggregations.ohmic_mohm = float(ohmic)    measurements.pulse.y_axis.current = cap["current_a"]    log.info(f"DCIR {dcir:.2f} mOhm at {DCIR_PULSE_A:.0f} A / {DCIR_PULSE_S:.0f} s, ohmic part {ohmic:.2f} mOhm")

Self Discharge

The analyzer holds the cell at its own OCV and logs the current it supplies, every 30 s for an hour. The first minutes are the cell's internal charge redistribution and mean nothing; the mean over the last ten minutes is the self-discharge current. The phase also expresses it as an OCV drop rate, for the people who still think in millivolts per day:

phases/self_discharge.py
import numpy as npfrom utils.recipe import SELF_DISCHARGE_SETTLE_MINdef self_discharge(measurements, station, log):    """Self-discharge current by the potentiostatic method: the analyzer    holds the cell at its own OCV and the current it has to supply, once    the cell's internal redistribution has settled, is the self-discharge.    About an hour instead of two to four weeks of OCV aging."""    cap = station.self_discharge_capture(SELF_DISCHARGE_SETTLE_MIN)    t = np.array(cap["time_min"])    i = np.array(cap["current_ua"])    settled = float(i[t >= SELF_DISCHARGE_SETTLE_MIN - 10.0].mean())    measurements.sd_current.x_axis = cap["time_min"]    measurements.sd_current.y_axis.current = cap["current_ua"]    measurements.sd_current.y_axis.current.aggregations.settled_ua = settled    # Equivalent OCV-drop rate for the people who still think in mV/day:    # a 5 Ah cell near 30 % SOC has dV/dQ of roughly 0.25 mV per mAh.    measurements.equivalent_mv_per_day = settled * 24.0 / 1000.0 * 0.25    log.info(f"Self-discharge {settled:.1f} uA settled, about {settled * 24.0 / 1000.0 * 0.25:.3f} mV/day equivalent")

Self-discharge current on a log axis over 60 minutes: starting near 170 µA, decaying through the 30 µA limit at 23 minutes, settling to 14 µA over the last ten minutes marked by a blue segment.

The mock cell under the analyzer: 14 µA settled, about 0.08 mV/day equivalent, against a 30 µA gate. A cell with a micro-short never comes down to the floor; the curve flattens in the hundreds. On the OCV-aging method the same cell would have needed two to four weeks of shelf time, and the anode-overhang equalization would still be confounding the reading at week four.

Mock Plug

CellStation stands in for five instruments on one fixture. It synthesizes one 21700 cell at 3.531 V, 14.2 mΩ at 1 kHz, a DC resistance 1.52 times that split 72/28 between ohmic and polarization with a 350 ms time constant, 11.4 µA of self-discharge under a 140 µA redistribution transient that decays with a 9 minute time constant, 68.4 g and 23.6 °C. The first impedance read raises a contact error, once. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.

On a real station, the class speaks SCPI to a Hioki BT3562 (:MEAS? returns resistance and voltage together), drives a Chroma 63600 or Kikusui PLZ-4W in transient mode for the pulse with the meter's voltage function logging, reads a Keysight BT2152B for the self-discharge current, and the fixture's load cell and IR sensor for the identity checks. Set TIME_SCALE = 1.0 when the analyzer streams the hour in real time, keep the AC and DC numbers on separate limits, and let a cold tray equilibrate rather than correcting its readings. The phases, measurements and limits stay the same.

Run your first test in minutes