
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.

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

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.

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.
- Setup: probe path, mass, surface temperature.
- Main: OCV against the shipping window, 10 mV bin.
- Main: 1 kHz AC impedance, grade, retried on a contact error.
- Main: 10 A pulse, ohmic step and DC resistance.
- Main: self-discharge current, 60 minutes, settled value.
- 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
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 --jsonThe 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:
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.releaseFramework features to notice:
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.- String
in.acir_gradevalidates against a list of grade letters; a cell outside every bin getsREJECTand fails with the reason. - One aggregation limited, one recorded. The pulse waveform carries
dcir_mohmwith a limit andohmic_mohmwithout; the second is there for the trend, not the verdict. - Time-scaled hour. The self-discharge phase has a 90 minute
timeoutfor the real analyzer and returns in milliseconds on the mock; thesettled_uaaggregation is the mean of the last ten minutes either way. - Station-scoped plug.
scope: stationkeeps the instruments open across the tray;setup:andteardown:land and lift the probes per cell. - Unit metadata for the next station.
ocv_bin,acir_gradeandcell_lotgo 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:
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:
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:
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:
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:
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")
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.