
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.

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

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.

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.
- Setup: visual result, vendor code.
- Main: mass, OCV, ACIR, C/2 discharge; reference stored on the plug.
- Main: six hours under vacuum, pressure curve.
- Main: mass, OCV, ACIR, C/2 discharge; four deltas to the reference.
- 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
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 --jsonThe Procedure File
procedure.yaml declares the unit, the bench plug, and the three stages with every measurement and limit:
procedure.yaml · 166 lines
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: VFramework features to notice:
- One function, two phases, prefixed keys.
phases/characterise.pywritesmass_<prefix>_g,ocv_<prefix>_vandacir_<prefix>_mohmwithsetattrand takes the discharge measurement as an argument, so the before and the after are the same code. - Absolutes before, deltas after. The four quantities carry data-sheet limits on the
preside and none on thepostside; the judgement after the exposure is the four*_delta_pctmeasurements. - A boolean with
==.visual_ok == truegates the run on the operator's inspection result. - A reference through the plug. Phases run in separate processes and cannot read each other's measurements back, so
pre_screenstores its readings on the bench plug andpost_screenrecalls them. - Time under a threshold.
exposure.pressure.hours_under_his 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:
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:
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
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:
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:
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.
