Battery Cell Screening with Vacuum

COTS 18650 cell screening with TofuPilot Framework: mass, OCV, ACIR and C/2 capacity before and after six hours under vacuum, NR-SRD-139 delta criteria.

TofuPilotIncoming InspectionPythonTofuPilot FrameworkGitHub
Battery cells under a clear vacuum dome connected to a tester
Run this procedure.

Get the complete source, dependencies and setup instructions from the template repository.

Open the source on GitHub ↗

Introduction

Cell Screening Overview

A smallsat battery is a few dozen commercial 18650 cells, and the programme that flies them has no say in how they were made. What it has is a screening: every cell measured, put through the part of the environment that finds a weak seal, and measured again. A cell that vents under vacuum loses mass; a cell with a soft short loses open-circuit voltage; a cell with damaged electrolyte loses capacity and gains impedance. The screening does not look for a bad cell in absolute terms, the vendor's data sheet does that. It looks for the cell that changed.

Close-up of one 18650 cell in a four-wire spring-contact holder with the current and sense cables

One cell in its four-wire holder: two contacts carry the current, two read the voltage, so the discharge curve is the cell's and not the holder's.

The criteria are written down for the ISS. The Nanoracks payload safety screening for lithium-ion cells, NR-SRD-139, is the reference most CubeSat programmes follow for COTS cells: 100 % screening with a visual inspection, mass, open-circuit voltage and capacity before and after a vacuum exposure and a vibration, and acceptance on the change: OCV within 0.1 %, capacity within 5 %, mass within 0.1 %. The AC impedance at 1 kHz and its change are the programme's own addition here, because a cell whose impedance moved has a problem the other three may not show yet. The vibration is run as its own procedure between the two characterisations; the delta criteria are the same.

Test Purpose

The procedure records one screening record per cell:

  • Visual inspection result and the vendor code, before the cell touches an instrument
  • Before: mass, OCV, AC impedance at 1 kHz, and a C/2 discharge to 3.0 V with the capacity from the curve, each against the data sheet
  • Six hours under vacuum, open-circuit, with the pressure curve and the time under the threshold
  • After: the same four, and the four deltas the cell is accepted on

Two panels: on the left the C/2 discharge curve from 4.1 V down to 3.0 V over 3.4 Ah, before in gray and after in dashed green almost on top of each other, a red dashed line at the 3.3 Ah minimum; on the right four pairs of bars, the measured change in OCV, capacity, mass and ACIR as a fraction of its limit in green, each well under the red limit bar.

The mock cell before and after six hours under vacuum: 3.400 then 3.380 Ah, a 0.59 % capacity change against 5 %, 0.016 % on OCV against 0.1 %, 0.010 % on mass against 0.1 %, 1.5 % on impedance against 20 %. A cell that vented reads 20 to 50 mg lighter, which is 0.04 to 0.1 % of its mass, and the balance sees it before the electrical numbers move.

The framework mechanics on show are one characterisation function shared by the before and after phases addressing prefixed measurement keys, absolute limits before and deltas after on the same four quantities, measurements kept without limits because the delta is the judgement, a boolean validated with ==, a reference passed between phases through the plug, and time under a threshold as an aggregation of a pressure curve.

Equipment & Setup

To screen cells at the pack line, the following are required:

  • A battery cycler channel with a four-wire holder, for the full charge and the C/2 discharge
  • A battery tester for OCV and AC impedance at 1 kHz
  • An analytical balance reading to a milligram or better
  • A small vacuum chamber with a gauge, a bell jar is enough, holding a rack of cells
  • The Device Under Test (DUT): a COTS 18650 cell, 3.5 Ah class, one at a time on the instruments and in a rack in the chamber
  • A TofuPilot Framework procedure to sequence the two characterisations and the exposure
  • The TofuPilot Dashboard to see the distribution of every delta across the lot, because a lot with a wide capacity spread is a lot to reject before matching

Hardware Components

Cycler and Battery Tester

A Neware, Arbin or Chroma channel charges the cell to 4.2 V and discharges it at C/2 to 3.0 V with the voltage logged against the charge passed; the capacity is the last point of the curve. A Hioki BT3562 reads the open-circuit voltage to 0.1 mV and the AC impedance at 1 kHz to 0.01 mΩ through the same four-wire holder. The OCV criterion is 0.1 %, which is 3.6 mV, so the tester's resolution is not the constraint; the cell's rest time before the reading is, and the procedure reads OCV before the discharge both times so the state is the same.

Cell screening bench: a rack of 18650 cells under a bell-jar vacuum chamber, an analytical balance with one cell on its pan, a battery tester instrument

The bench: the rack of cells under the bell jar for the exposure, the balance, the tester. Cells are screened one at a time on the instruments and together in the chamber.

Balance and Chamber

The mass criterion is 0.1 % of 48 g, which is 48 mg; a balance reading to 1 mg resolves a vent at a twentieth of the limit. A Sartorius or Mettler analytical balance over RS-232 does it. The chamber is a bell jar on a plate with a roughing pump and a Pirani gauge; the exposure is six hours at or below 1e-3 hPa, open-circuit, and the pressure curve is recorded so the time actually spent under the threshold is a number on the record rather than an assumption.

Where the Limits Come From

The delta criteria on OCV, capacity and mass are NR-SRD-139. The impedance delta of 20 % is the programme's. The absolute limits before the exposure, 3.30 Ah, 3.55 to 3.75 V at the delivery state of charge, 30 mΩ and 47.5 to 49.0 g, are the vendor's data sheet with margin. The exposure of six hours at 1e-3 hPa is the programme's screening plan. A customer keeps the deltas as they are and tightens the absolute windows from the lot's distribution, because the cells that go into one pack have to match each other more than they have to meet the sheet.

Test Procedure

Overview

The procedure maps the screening onto the framework's three stages. The visual result lives in setup: so a dented cell never reaches the cycler. The three main phases are chained with depends_on because the after only makes sense after the exposure, and the exposure after the before. The last OCV lives in teardown: so the cell goes to storage with a known voltage on the record whatever the outcome.

  1. Setup: visual result, vendor code.
  2. Main: mass, OCV, ACIR, C/2 discharge; reference stored on the plug.
  3. Main: six hours under vacuum, pressure curve.
  4. Main: mass, OCV, ACIR, C/2 discharge; four deltas to the reference.
  5. Teardown: OCV at storage.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for per-serial trending across the lot.

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
identify.py
characterise.py
pre_screen.py
vacuum.py
post_screen.py
release.py
plugs
cell_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The CellBench plug is a mock of the cycler, the tester, the balance and the chamber together, synthesizing a healthy 3.42 Ah cell at 3.652 V and 23 mΩ that loses 4 mg, 0.4 mV and 0.6 % of its capacity under vacuum, so the procedure runs end-to-end without an instrument or a cell connected.

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 bench plug, and the three stages with every measurement and limit:

procedure.yaml · 166 lines
procedure.yaml
name: Battery Cell Screening with Vacuumversion: 0.1.0description: 100 % screening of COTS 18650 cells for a smallsat battery. Visual in setup, then mass, OCV, ACIR at 1 kHz and a C/2 discharge capacity before and after six hours under vacuum, the cell accepted on how little the four numbers changed, after the NR-SRD-139 criteria.unit:  auto_identify: true  serial_number:    description: "Scan the cell's laser mark"    placeholder: "CELL-35E-000000"    pattern: "^CELL-35E-\\d{6}$"    default_value: "CELL-35E-041872"  part_number:    default_value: "INR18650-35E"  batch_number:    default_value: "LOT-2026-09"plugs:  - name: Cell Bench    description: "Cycler channel with a four-wire holder, AC impedance meter, analytical balance, vacuum chamber with gauge (mock, one plug per bench)"    python: plugs.cell_bench:CellBench    key: benchsetup:  - name: Identify    key: identify    python: phases.identify    measurements:      - name: Visual        key: visual_ok        validators:          - {operator: "==", expected_value: true}main:  - name: Pre Screen    key: pre_screen    python: phases.pre_screen    timeout: 4h    measurements:      - name: Mass Before        key: mass_pre_g        unit: g        validators:          - {operator: ">=", expected_value: 47.5}          - {operator: "<=", expected_value: 49.0}      - name: OCV Before        key: ocv_pre_v        unit: V        validators:          - {operator: ">=", expected_value: 3.55}          - {operator: "<=", expected_value: 3.75}      - name: ACIR Before        key: acir_pre_mohm        unit: mΩ        validators:          - {operator: "<=", expected_value: 30.0}      - name: Discharge Before        key: discharge_pre        title: C/2 discharge to 3.0 V before the vacuum exposure        x_axis:          legend: Capacity          unit: Ah        y_axis:          - legend: Voltage            key: voltage            unit: V            aggregations:              - type: capacity_ah                unit: Ah                validators:                  - {operator: ">=", expected_value: 3.30}  - name: Vacuum    key: vacuum    python: phases.vacuum    depends_on: [pre_screen]    timeout: 12h    ui:      components:        - key: vacuum_progress          type: progress          label: "Vacuum exposure"          description: "6 h at or below 1e-3 hPa, open-circuit"          default_value: 0          max: 100    measurements:      - name: Exposure        key: exposure        title: Chamber pressure through the exposure        x_axis:          legend: Time          unit: h        y_axis:          - legend: Pressure            key: pressure            unit: hPa            aggregations:              - type: min_hpa                unit: hPa                validators:                  - {operator: "<=", expected_value: 1.0e-3}              - type: hours_under_h                unit: h                validators:                  - {operator: ">=", expected_value: 5.5}  - name: Post Screen    key: post_screen    python: phases.post_screen    depends_on: [vacuum]    timeout: 4h    measurements:      - name: Mass After        key: mass_post_g        unit: g      - name: OCV After        key: ocv_post_v        unit: V      - name: ACIR After        key: acir_post_mohm        unit: mΩ      - name: Discharge After        key: discharge_post        title: C/2 discharge to 3.0 V after the vacuum exposure        x_axis:          legend: Capacity          unit: Ah        y_axis:          - legend: Voltage            key: voltage            unit: V            aggregations:              - type: capacity_ah                unit: Ah                validators:                  - {operator: ">=", expected_value: 3.30}      - name: OCV Delta        key: ocv_delta_pct        unit: "%"        description: NR-SRD-139 acceptance on the change in open-circuit voltage.        validators:          - {operator: "<=", expected_value: 0.1}      - name: Capacity Delta        key: capacity_delta_pct        unit: "%"        validators:          - {operator: "<=", expected_value: 5.0}      - name: Mass Delta        key: mass_delta_pct        unit: "%"        description: A cell that lost mass under vacuum vented; the balance sees it before anything else does.        validators:          - {operator: "<=", expected_value: 0.1}      - name: ACIR Delta        key: acir_delta_pct        unit: "%"        validators:          - {operator: "<=", expected_value: 20.0}teardown:  - name: Release    key: release    python: phases.release    measurements:      - name: OCV At Storage        key: ocv_storage_v        unit: V

Framework features to notice:

  1. One function, two phases, prefixed keys. phases/characterise.py writes mass_<prefix>_g, ocv_<prefix>_v and acir_<prefix>_mohm with setattr and takes the discharge measurement as an argument, so the before and the after are the same code.
  2. Absolutes before, deltas after. The four quantities carry data-sheet limits on the pre side and none on the post side; the judgement after the exposure is the four *_delta_pct measurements.
  3. A boolean with ==. visual_ok == true gates the run on the operator's inspection result.
  4. A reference through the plug. Phases run in separate processes and cannot read each other's measurements back, so pre_screen stores its readings on the bench plug and post_screen recalls them.
  5. Time under a threshold. exposure.pressure.hours_under_h is computed from the pressure curve; the exposure is judged on what happened, not on how long the phase ran.

Identify

The setup phase records the operator's visual inspection result and the vendor code:

phases/identify.py
def identify(measurements, bench, unit, log):    """Setup: visual inspection result and vendor code on the record;    a dented, scratched or leaking cell goes no further."""    ident = bench.identify()    measurements.visual_ok = ident["visual_ok"]    unit.metadata["vendor_code"] = ident["vendor_code"]    log.info(f"Cell {unit.serial_number}: {ident['vendor_code']}, visual {'ok' if ident['visual_ok'] else 'REJECT'}")

Pre Screen

The before phase calls the shared function with the pre prefix and the discharge_pre measurement, and stores the readings on the plug:

phases/pre_screen.py
from phases.characterise import characterisedef pre_screen(measurements, bench, log):    """Characterisation before the vacuum exposure; the reference kept on    the bench plug because phases cannot read each other's measurements."""    ref = characterise(measurements, bench, log, "pre", measurements.discharge_pre)    bench.store("pre", ref)

The shared function reads mass, OCV and impedance, charges the cell, discharges it at C/2, records the curve and takes the capacity from its last point:

phases/characterise.py · 23 lines
phases/characterise.py
import numpy as npdef characterise(measurements, bench, log, prefix, curve_meas):    """Shared by the pre and post phases: mass, OCV, ACIR at 1 kHz, then    a full charge and a C/2 discharge to the cutoff with the capacity    read from the curve."""    mass = bench.mass_g()    ocv = bench.ocv_v()    acir = bench.acir_mohm()    bench.charge_full()    cap = bench.discharge()    q = np.array(cap["capacity_ah"])    capacity = float(q[-1])    setattr(measurements, f"mass_{prefix}_g", mass)    setattr(measurements, f"ocv_{prefix}_v", ocv)    setattr(measurements, f"acir_{prefix}_mohm", acir)    curve_meas.x_axis = cap["capacity_ah"]    curve_meas.y_axis.voltage = cap["voltage_v"]    curve_meas.y_axis.voltage.aggregations.capacity_ah = capacity    log.info(f"{prefix}: {mass:.4f} g, OCV {ocv:.4f} V, ACIR {acir:.2f} mohm, {capacity:.3f} Ah at C/2")    return {"mass": mass, "ocv": ocv, "acir": acir, "capacity": capacity}

Vacuum

The exposure returns the pressure curve; the phase records it with the lowest pressure reached and the hours spent under the threshold:

phases/vacuum.py
import numpy as npfrom utils.recipe import VACUUM_H, VACUUM_HPA_MAXdef vacuum(measurements, bench, ui, log):    """Six hours under vacuum, open-circuit. A cell with a weak seal or a    gassing electrolyte vents, and the post characterisation sees it."""    cap = bench.vacuum_exposure(VACUUM_H)    ui.vacuum_progress = 100    t = np.array(cap["time_h"]); p = np.array(cap["pressure_hpa"])    under = t[p <= VACUUM_HPA_MAX]    hours_under = float(under[-1] - under[0]) if under.size > 1 else 0.0    measurements.exposure.x_axis = cap["time_h"]    measurements.exposure.y_axis.pressure = cap["pressure_hpa"]    measurements.exposure.y_axis.pressure.aggregations.min_hpa = float(p.min())    measurements.exposure.y_axis.pressure.aggregations.hours_under_h = hours_under    log.info(f"Vacuum {t[-1]:.1f} h, {p.min():.1e} hPa at best, {hours_under:.1f} h under {VACUUM_HPA_MAX:.0e} hPa")

Post Screen

The after phase calls the same function with the post prefix, recalls the reference, and computes the four deltas:

phases/post_screen.py
from phases.characterise import characterisedef post_screen(measurements, bench, log):    """Characterisation after the vacuum exposure, and the four deltas    against the reference: the numbers the cell is accepted on."""    post = characterise(measurements, bench, log, "post", measurements.discharge_post)    ref = bench.recall("pre")    measurements.ocv_delta_pct = float(100.0 * abs(post["ocv"] - ref["ocv"]) / ref["ocv"])    measurements.capacity_delta_pct = float(100.0 * abs(post["capacity"] - ref["capacity"]) / ref["capacity"])    measurements.mass_delta_pct = float(100.0 * abs(post["mass"] - ref["mass"]) / ref["mass"])    measurements.acir_delta_pct = float(100.0 * abs(post["acir"] - ref["acir"]) / ref["acir"])    log.info(f"Deltas: OCV {100.0 * abs(post['ocv'] - ref['ocv']) / ref['ocv']:.3f} %, capacity {100.0 * abs(post['capacity'] - ref['capacity']) / ref['capacity']:.2f} %, mass {100.0 * abs(post['mass'] - ref['mass']) / ref['mass']:.4f} %, ACIR {100.0 * abs(post['acir'] - ref['acir']) / ref['acir']:.1f} %")

Mock Plug

CellBench stands in for the cycler channel, the battery tester, the balance and the chamber. It draws a cell with 3.42 Ah at C/2, 3.652 V, 23 mΩ and 48.15 g, a discharge curve with the plateau and the knee of an NMC cell less the IR drop at C/2, a chamber that pumps to 4e-4 hPa in minutes and holds, and an exposure that takes 4 mg, 0.4 mV, 0.6 % of capacity and adds 0.3 mΩ. The discharge and the exposure are time-scaled and return in one call each. 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, and the reference crosses phases through the plug's store and recall.

On a real station, the class speaks the cycler's API for the charge and the discharge with the curve read back, SCPI to the Hioki tester for OCV and ACIR, the balance's serial protocol, and the chamber's pump and gauge. Set TIME_SCALE = 1.0 in utils/recipe.py, run the vibration screening as its own procedure between the two characterisations if the programme requires it, and take the absolute windows from the cell's data sheet. The phases, measurements and limits stay the same.

Run your first test in minutes