Skip to content

Pack EOL Hipot and Insulation Test

End-of-line electrical safety of a 96S traction pack: DC withstand on both HV terminals, 500 V insulation resistance in ohm per volt, 25 A ground bond.

TofuPilotEnd-of-LinePythonTofuPilot FrameworkGitHub
Pack EOL Hipot and Insulation Test

Introduction

Pack Electrical Safety Overview

A traction or stationary storage pack is a 400 V DC source inside a metal enclosure that people touch. The end-of-line electrical safety test proves three things about every pack before it ships: that the insulation between the high-voltage bus and the enclosure survives a voltage well above anything it will see in service (dielectric withstand, or hipot), that this insulation is also high enough in resistance to keep the leakage current through a person below the touch limit (insulation resistance), and that every exposed conductive part is bonded to the protective earth stud with a resistance low enough to trip the upstream protection (ground bond). Of the eighteen standards routinely quoted around battery testing, this is the one test that a standard actually mandates on every unit: UL 1973 clause 40.3 requires a production-line dielectric withstand on packs above 60 V DC, with a shortened alternative at a higher test voltage so it fits a line takt. Everything else in those standards is a type test on a handful of samples.

A traction battery pack case with two orange HV terminals on top, the red HV test clip on one terminal and the black four-wire ground bond clamp on the stud on the side.

The three connections of the test on one pack: the analyzer's HV clip on the terminal under test, the return on the chassis, and the four-wire ground bond clamp on the PE stud.

Lithium packs are tested with DC, not AC. The HV bus sees the enclosure through the EMI filter capacitors and the cell-to-case capacitance, together in the order of 100 nF; at 50 Hz an AC test voltage drives a reactive current through that capacitance that swamps the real leakage by three orders of magnitude, so the analyzer trips on a healthy pack. Under DC the capacitance draws current only while the voltage ramps, then the current settles to the resistive leakage and the arc detector watches the dwell. Hioki's application notes for their ST5680 series say this outright, and it is why every EV pack line runs DC withstand.

Test Purpose

The procedure records one safety fingerprint per pack:

  • Gate: operator interlock confirmed, contactors open, pack at storage SOC, BMS isolation monitor switched off
  • DC withstand on HV+ and on HV- to chassis: leakage current through the ramp and the dwell, peak and settled values, arc events, the analyzer's own verdict
  • Insulation resistance at 500 V DC on both terminals: the 60 s value in MΩ and normalised to Ω per volt of working voltage
  • Ground bond at 25 A, four-wire, from the PE stud to three exposed conductive parts
  • Residual voltage after discharge, and the isolation monitor handed back to the BMS

Leakage current in µA over 65 s for HV+ in green and HV- in blue: 51 to 54 µA during the 5 s ramp, a drop to 9 µA the moment the ramp ends, an exponential tail settling to 3 µA. The test voltage as a dotted line on the right axis rising to 2550 V. The 50 µA settled limit drawn over the last 30 s.

The mock pack under 2550 V DC: the 51 µA plateau during the ramp is 100 nF of Y capacitance charging at 510 V/s, the drop at 5 s is the ramp ending, the tail is dielectric absorption, and the 3 µA the current settles to is the pack's 850 MΩ of insulation. The 5 mA limit of ISO 16750-2 is off the chart; the customer's 50 µA settled limit is what a wet connector or a pinched harness would fail.

The framework mechanics on show are an operator switch bound to a validated measurement, a setup gate and a teardown that always discharges, a station-scoped plug held across packs, multi-dimensional measurements with custom aggregations where a standard's limit and an internal limit sit on the same curve, and string and integer validators on the instrument's own verdict.

Equipment & Setup

To run this test on a pack line, the following are required:

  • An electrical safety analyzer with DC withstand to at least 3 kV, insulation resistance at 500 or 1000 V DC, a four-wire ground bond source of 25 to 30 A, arc detection and an open/short check on the leads
  • A safety fixture: hood with interlock, HV clips on both terminals, ground bond clamp on the PE stud, discharge path
  • A CAN link to the pack BMS to switch the isolation monitor off and back on
  • The Device Under Test (DUT): an assembled pack at storage SOC, contactors open
  • A TofuPilot Framework procedure to sequence the tests, log the curves and validate the limits
  • The TofuPilot Dashboard to keep the per-pack evidence the standards ask you to keep

Hardware Components

Safety Analyzer

The Chroma 19032 does the whole sequence in one box: AC withstand 0.05 to 5 kV, DC withstand 0.05 to 6 kV with cutoff to 12 mA and 0.1 µA current resolution, insulation resistance 0.1 MΩ to 50 GΩ at 50 V to 1 kV, ground bond 1 to 30 A four-wire with 0.1 mΩ resolution over 10 to 510 mΩ, test time 0.3 to 999 s, programmable arc detection, and an Open/Short Check that catches the failure mode every hipot station fears: a lead that fell off reading as a clean pass. The Hioki ST5680 is the withstand specialist for lithium packs, DC to 8 kV and 20 mA, 0.001 µA current resolution, 500 kS/s waveform capture of the leakage during the dwell and arc detection. The Chroma 19572 covers ground bond alone at 3 to 45 A when a line separates the two. Kikusui's TOS9200 and Vitrek's 95X are the usual alternatives.

Pack EOL safety station: a traction battery pack under a transparent safety hood on the left with the HV clips and the ground bond clamp attached, cabled to an electrical safety analyzer with a large red pushbutton on the right.

A single-fixture station: the pack under the interlocked hood, the analyzer's HV leads and the four-wire bond clamp into the fixture; the test computer sits off the plate, on the CAN bus to the BMS and on the analyzer's remote interface.

Test Voltages and Where the Limits Come From

The withstand voltage derives from the pack's maximum working voltage, not from a constant. For a 96S NMC pack, U_max is 96 × 4.2 = 403 V; the classic 2U + 1000 V AC rule gives 1806 V AC, and its DC equivalent (× 1.414) rounds to 2550 V DC. A real CB report for an AC-DC supply used 4700 V AC for reinforced insulation and 2000 V AC for basic, both derived from the working voltage; copying any of these numbers into another product is the error to avoid.

The insulation limits are two different numbers that get conflated. The regulatory floor under ISO 6469-3 and UN R100 (and FMVSS 305 in the US) is 100 Ω/V for a DC bus with continuous isolation monitoring and 500 Ω/V for AC; for a 403 V pack that is 40 kΩ, and it is a survival criterion after the crash and abuse tests, not a production gate. A production gate sits between 10 and 100 MΩ and is the customer's own specification. This template validates both on the same curve: the 60 s value ≥ 100 MΩ and the normalised value ≥ 500 Ω/V, so the report shows the pack cleared the floor by four orders of magnitude and the gate by a factor of eight. IEC 62133-2 clause 5.2 gives the portable-battery version of the same idea: ≥ 5 MΩ at 500 V DC read 60 s after the voltage is applied, which is where the 60 s dwell comes from.

Ground bond: ≤ 100 mΩ at 25 A is the common requirement, verified in the same CB report ("cannot be higher than 100 mΩ", 25 A for 1 minute). The four-wire method is not optional at that level: the clamp and lead resistance of a two-wire measurement are the same order as the limit.

Two Procedural Hazards

Switch off the isolation monitor. The BMS's own insulation monitoring device measures the same resistance the analyzer is about to measure, through a resistor of its own to chassis. Left on, it sits in parallel with the pack's insulation and the analyzer reads the IMD's resistor instead of the pack. GB 38031 says to disconnect it for the measurement; this template does it over CAN in the setup phase and validates that it happened.

Never retest a pack that arced. Hipot is the one end-of-line test where a retest-on-fail policy is unsafe. An arc across a contaminant, a solder ball, a strand of wire or flux residue on a creepage path, burns the contaminant away. The second test passes, the pack ships, and the creepage path is still short by whatever the contaminant bridged. The arc_events == 0 validator is deliberately not wrapped in a retry.

Test Procedure

Overview

The procedure maps the sequence onto the framework's three stages. Safety lives in setup: so no high voltage leaves the analyzer before the operator has confirmed the hood and the clips and the BMS isolation monitor is off. The discharge lives in teardown: so it runs even when a withstand phase fails and no pack is left charged to 2.5 kV under the hood.

  1. Setup: interlock switch, contactors open, pack voltage in the storage window, isolation monitor off.
  2. Main: DC withstand HV+ to chassis, leakage logged, arc count, verdict.
  3. Main: DC withstand HV- to chassis.
  4. Main: insulation resistance at 500 V DC on both terminals, MΩ and Ω/V.
  5. Main: ground bond at 25 A on three points.
  6. Teardown: discharge, residual voltage, isolation monitor back on.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot as the per-pack evidence.

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
safety_gate.py
hipot_hv_plus.py
hipot_hv_minus.py
insulation_resistance.py
ground_bond.py
discharge_release.py
plugs
safety_analyzer.py
pack_fixture.py
utils
recipe.py
ui.json
pyproject.toml
README.md

You can find the full source on GitHub. The SafetyAnalyzer mock returns the whole 65 s dwell in one call and the PackFixture mock reports a pack at storage SOC with the contactors open, so the procedure runs end-to-end in seconds without a pack or an analyzer connected.

tofupilot run .

For CI or bench automation, ui.json pre-bakes the interlock switch and the run executes headless:

tofupilot run . --no-tui --no-kiosk --json --ui-values ui.json --ui-timeout 60

The Procedure File

procedure.yaml declares the unit, the two plugs (the analyzer at station scope, so its leads stay connected between packs), and the three stages with every measurement and limit:

procedure.yaml
name: Pack EOL Hipot and Insulationversion: 0.1.0description: End-of-line electrical safety of a 96S traction pack. Operator interlock and isolation-monitor gate, DC withstand on both HV terminals with the leakage logged, 500 V insulation resistance normalised to ohm per volt, 25 A ground bond, discharge and residual check.unit:  auto_identify: true  serial_number:    description: "Scan the pack label before closing the fixture"    placeholder: "PACK-96S-000000"    pattern: "^PACK-96S-\\d{6}$"    default_value: "PACK-96S-004217"  part_number:    default_value: "PK-96S-NMC-60"  batch_number:    default_value: "WK-2026-37"plugs:  - name: Safety Analyzer    description: "5-in-1 electrical safety analyzer, leads stay on between packs (mock Chroma 19032-class)"    python: plugs.safety_analyzer:SafetyAnalyzer    key: analyzer    scope: station  - name: Pack Fixture    description: Fixture PLC and CAN link to the pack BMS (mock)    python: plugs.pack_fixture:PackFixture    key: fixturesetup:  - name: Safety Gate    key: safety_gate    python: phases.safety_gate    ui:      components:        - key: interlock          type: switch          label: "Fixture closed and interlock armed"          description: "Confirm the hood is down, the HV clips are on both terminals and the PE clip is on the chassis stud"          required: true          bind: measurements.interlock_armed    measurements:      - name: Interlock Armed        key: interlock_armed        validators:          - {operator: "==", expected_value: true}      - name: Contactors Open        key: contactors_open        validators:          - {operator: "==", expected_value: true}      - name: Pack Voltage        key: pack_voltage_v        unit: V        description: Storage SOC window for a 96S NMC pack, 3.60 to 3.80 V per cell.        validators:          - {operator: ">=", expected_value: 345.0}          - {operator: "<=", expected_value: 365.0}      - name: Isolation Monitor Disabled        key: imd_disabled        description: The BMS isolation monitor's measuring resistance sits in parallel with the pack insulation; it must be off during the test.        validators:          - {operator: "==", expected_value: true}main:  - name: Hipot HV Plus    key: hipot_hv_plus    python: phases.hipot_hv_plus    timeout: 5m    measurements:      - name: Leakage HV Plus        key: leakage_plus        title: DC withstand HV+ to chassis, 2550 V, 5 s ramp, 60 s dwell        x_axis:          legend: Time          unit: s        y_axis:          - legend: Leakage            key: leak            unit: µA            aggregations:              - type: peak_ua                unit: µA                validators:                  - {operator: "<=", expected_value: 5000.0}              - type: settled_ua                unit: µA                validators:                  - {operator: "<=", expected_value: 50.0}      - name: Arc Events HV Plus        key: arc_events_plus        description: "Arc detector count during the dwell. Any arc is a fail, and a failed pack is not retested: the arc burns away the contaminant that caused it."        validators:          - {operator: "==", expected_value: 0}      - name: Judgment HV Plus        key: judgment_plus        validators:          - {operator: "==", expected_value: PASS}  - name: Hipot HV Minus    key: hipot_hv_minus    python: phases.hipot_hv_minus    depends_on: [hipot_hv_plus]    timeout: 5m    measurements:      - name: Leakage HV Minus        key: leakage_minus        title: DC withstand HV- to chassis, 2550 V, 5 s ramp, 60 s dwell        x_axis:          legend: Time          unit: s        y_axis:          - legend: Leakage            key: leak            unit: µA            aggregations:              - type: peak_ua                unit: µA                validators:                  - {operator: "<=", expected_value: 5000.0}              - type: settled_ua                unit: µA                validators:                  - {operator: "<=", expected_value: 50.0}      - name: Arc Events HV Minus        key: arc_events_minus        validators:          - {operator: "==", expected_value: 0}      - name: Judgment HV Minus        key: judgment_minus        validators:          - {operator: "==", expected_value: PASS}  - name: Insulation Resistance    key: insulation_resistance    python: phases.insulation_resistance    depends_on: [hipot_hv_minus]    timeout: 5m    measurements:      - name: IR HV Plus        key: ir_plus        title: Insulation resistance HV+ to chassis at 500 V DC        x_axis:          legend: Time          unit: s        y_axis:          - legend: Resistance            key: ir            unit: MΩ            aggregations:              - type: at_60s_mohm                unit: MΩ                validators:                  - {operator: ">=", expected_value: 100.0}              - type: ohm_per_volt                unit: Ω/V                validators:                  - {operator: ">=", expected_value: 500.0}      - name: IR HV Minus        key: ir_minus        title: Insulation resistance HV- to chassis at 500 V DC        x_axis:          legend: Time          unit: s        y_axis:          - legend: Resistance            key: ir            unit: MΩ            aggregations:              - type: at_60s_mohm                unit: MΩ                validators:                  - {operator: ">=", expected_value: 100.0}              - type: ohm_per_volt                unit: Ω/V                validators:                  - {operator: ">=", expected_value: 500.0}  - name: Ground Bond    key: ground_bond    python: phases.ground_bond    depends_on: [insulation_resistance]    measurements:      - name: Bond Chassis Lug        key: bond_chassis_lug_mohm        unit: mΩ        validators:          - {operator: "<=", expected_value: 100.0}      - name: Bond Service Cover        key: bond_service_cover_mohm        unit: mΩ        validators:          - {operator: "<=", expected_value: 100.0}      - name: Bond HV Connector Bracket        key: bond_hv_connector_bracket_mohm        unit: mΩ        validators:          - {operator: "<=", expected_value: 100.0}teardown:  - name: Discharge Release    key: discharge_release    python: phases.discharge_release    measurements:      - name: Residual Voltage        key: residual_voltage_v        unit: V        description: HV bus to chassis one second after the analyzer's discharge; below the 60 V DC touch limit before the hood opens.        validators:          - {operator: "<=", expected_value: 60.0}      - name: Isolation Monitor Restored        key: imd_restored        validators:          - {operator: "==", expected_value: true}

Framework features to notice:

  1. Operator switch bound to a measurement. The interlock switch declares bind: measurements.interlock_armed and the measurement validates == true, so the report records what the operator confirmed, not that a button was pressed. ui.json pre-bakes it for headless runs.
  2. Station-scoped plug. scope: station on the analyzer instantiates it once for the station's lifetime; the leads stay connected and the open/short check runs once, not per pack.
  3. Two limits on one curve. The leakage curve carries peak_ua <= 5000 (ISO 16750-2) and settled_ua <= 50 (customer spec) as two aggregations; the report shows which one a failing pack broke.
  4. The instrument's verdict as a measurement. judgment_plus == PASS and arc_events_plus == 0 record what the analyzer itself decided, next to the framework's own limits on the curve.
  5. teardown: always runs. The discharge and the residual check execute after a failed withstand too.

Safety Gate

The setup phase reads what the fixture knows before the analyzer's output is enabled: contactors open, pack voltage inside the storage window, and the isolation monitor switched off over CAN and read back. The pack voltage at test goes onto the unit metadata:

phases/safety_gate.py
def safety_gate(measurements, fixture, unit, log):    """Setup: operator interlock (switch bound to a measurement), contactors    open, pack at storage SOC, BMS isolation monitor disabled. No high    voltage leaves the analyzer before this phase passes."""    measurements.contactors_open = fixture.contactors_open()    v = fixture.pack_voltage()    measurements.pack_voltage_v = v    fixture.disable_isolation_monitor()    measurements.imd_disabled = not fixture.isolation_monitor_enabled()    unit.metadata["pack_voltage_at_test_v"] = v    log.info(f"Pack {unit.serial_number} at {v:.1f} V, contactors open, isolation monitor disabled")

The hardware interlock on the hood stays wired to the analyzer's interlock input; the switch is the operator-facing layer, not the protection.

Hipot HV Plus and HV Minus

Each withstand phase asks the analyzer for one capture: ramp to 2550 V DC in 5 s, hold 60 s, leakage sampled at 10 Hz. The peak is the Y capacitance charging during the ramp and is limited by the standard's 5 mA; the settled value is the mean over the last 30 s of the dwell and is limited by the customer's 50 µA. The arc count and the analyzer's own PASS/FAIL come back with the curve:

phases/hipot_hv_plus.py
import numpy as npfrom utils.recipe import DCW_DWELL_S, DCW_RAMP_S, DCW_Vdef hipot_hv_plus(measurements, analyzer, log):    """DC withstand, HV+ to chassis: ramp to the test voltage, hold, log the    leakage. The peak is the ramp charging the Y capacitance; the settled    value is the real insulation current."""    cap = analyzer.dc_withstand("hv_plus", DCW_V, DCW_RAMP_S, DCW_DWELL_S)    leak = np.array(cap["leak_ua"])    t = np.array(cap["time_s"])    settled = float(leak[t >= DCW_RAMP_S + 30.0].mean())    measurements.leakage_plus.x_axis = cap["time_s"]    measurements.leakage_plus.y_axis.leak = cap["leak_ua"]    measurements.leakage_plus.y_axis.leak.aggregations.peak_ua = float(leak.max())    measurements.leakage_plus.y_axis.leak.aggregations.settled_ua = settled    measurements.arc_events_plus = int(cap["arc_events"])    measurements.judgment_plus = cap["judgment"]    log.info(f"HV+ at {DCW_V} V DC: peak {leak.max():.1f} uA, settled {settled:.1f} uA, arcs {cap['arc_events']}")

hipot_hv_minus.py is the same phase on the other terminal, chained with depends_on because both use the analyzer. Testing the two terminals separately, rather than shorting HV+ to HV- and testing the bus once, keeps the pack's own voltage out of the measurement and tells the report which side of the bus leaks.

Insulation Resistance

At 500 V DC the analyzer samples the resistance once a second for 60 s. The reading rises over the dwell as the dielectric absorbs charge, which is why the standards specify the value at 60 s and not at first contact. The phase records the curve per terminal and validates the 60 s value twice, in MΩ against the production gate and in Ω/V against the regulatory floor:

phases/insulation_resistance.py
from utils.recipe import IR_DWELL_S, IR_TEST_V, U_MAX_Vdef insulation_resistance(measurements, analyzer, log):    """500 V DC insulation resistance on both HV terminals, the 60 s value    validated in MOhm and normalised to ohm per volt of working voltage."""    for terminal, key in (("hv_plus", "ir_plus"), ("hv_minus", "ir_minus")):        cap = analyzer.insulation_resistance(terminal, IR_TEST_V, IR_DWELL_S)        ir_60 = float(cap["ir_mohm"][-1])        curve = getattr(measurements, key)        curve.x_axis = cap["time_s"]        curve.y_axis.ir = cap["ir_mohm"]        curve.y_axis.ir.aggregations.at_60s_mohm = ir_60        curve.y_axis.ir.aggregations.ohm_per_volt = ir_60 * 1e6 / U_MAX_V        log.info(f"{terminal}: {ir_60:.0f} MOhm at 60 s, {ir_60 * 1e6 / U_MAX_V:,.0f} ohm/V")

Insulation resistance in MΩ over 60 s for both terminals, rising from 600 MΩ at t=0 to 851 MΩ (HV+) and 910 MΩ (HV-) at 60 s. The 100 MΩ customer limit as a dashed line near the bottom and the 0.20 MΩ regulatory floor as a dotted line on the axis.

The two curves of the mock pack: 30 % of dielectric absorption over the first 20 s, then flat. The regulatory floor of 500 Ω/V × 403 V = 0.20 MΩ is invisible at this scale, which is the point of validating both numbers: the floor is what the pack must survive after a crash, the gate is what a good pack looks like leaving the line.

Ground Bond

25 A through the PE stud to each exposed conductive part, four-wire, the drop read at the clamp. Three points on this pack: the chassis lug, the service cover, and the bracket of the HV connector, each with its own scalar measurement and the same 100 mΩ limit:

phases/ground_bond.py
from utils.recipe import GROUND_BOND_A, GROUND_BOND_POINTSdef ground_bond(measurements, analyzer, log):    """25 A 4-wire bond resistance from the PE stud to each exposed    conductive part; a painted bracket or a missing star washer shows here."""    for point in GROUND_BOND_POINTS:        r = analyzer.ground_bond(point, GROUND_BOND_A)        setattr(measurements, f"bond_{point}_mohm", r)        log.info(f"{point}: {r:.1f} mOhm at {GROUND_BOND_A:.0f} A")

Three bars of bond resistance: chassis lug 8.5 mΩ, service cover 31.2 mΩ, HV connector bracket 43.7 mΩ, all under the 100 mΩ dashed limit.

The three bond points of the mock pack. The bracket is the one to watch across a batch: it bonds through a painted surface and a star washer, and a missing washer shows as a jump from 44 mΩ to several hundred.

Discharge Release

The teardown phase discharges the bus through the analyzer, reads the residual one second later against the 60 V DC touch limit, and hands the isolation monitor back to the BMS:

phases/discharge_release.py
def discharge_release(measurements, analyzer, fixture, log):    """Teardown: discharge the HV bus to chassis, verify the residual, hand    the isolation monitor back to the BMS. Runs after a failed phase too, so    no pack leaves the fixture charged to the test voltage."""    residual = analyzer.discharge()    measurements.residual_voltage_v = residual    fixture.enable_isolation_monitor()    measurements.imd_restored = fixture.isolation_monitor_enabled()    log.info(f"Residual {residual:.1f} V after discharge, isolation monitor restored")

Mock Plugs

SafetyAnalyzer models a pack with 100 nF of HV-to-chassis capacitance and 850 MΩ (HV+) and 910 MΩ (HV-) of insulation: the withstand current is C × dV/dt during the ramp, V/R plus a decaying absorption tail after it, and the IR curve is the same 850 MΩ approached from 30 % below with a 12 s time constant. The ground bond returns 8.5, 31 and 44 mΩ for the three points. PackFixture reports contactors open, 3.70 V per cell and an isolation monitor that obeys the disable command. Plug calls cross a JSON boundary, so the analyzer returns plain lists; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.

On a real line, SafetyAnalyzer becomes a pyvisa class speaking SCPI to a Chroma 19032, a Hioki ST5680 or a Kikusui TOS9200 (DCW:VOLT, DCW:RAMP, START, then a fetch of the leakage log), and PackFixture becomes the fixture PLC plus a python-can bus to the BMS. Keep the analyzer's arc detector and open/short check enabled, set TIME_SCALE = 1.0 when the analyzer streams the dwell in real time, and keep the rule that a pack which arced goes to analysis, not back into the fixture. The phases, measurements and limits stay the same.

Run your first test in minutes