---
title: "BMS Validation Testing: Automating the Bench"
description: "BMS validation testing on an automated bench: cell simulators, voltage and temperature accuracy, balancing, OV/UV/OC/OT trips, CAN checks and IEC 62619."
url: https://galoislabs.ai/blog/bms-validation-testing
author: Alex Hernandez
author_url: https://galoislabs.ai/blog/authors/alex-hernandez
published: "2026-08-07"
topic: Industries
publisher: Galois Labs
---

# BMS validation testing: cell simulation, protection trips and CAN checks in Python

![A series stack of identical battery cells in elevation, one cell slid out and joined by a short ribbon to a plain BMS board.](https://galoislabs.ai/blog/figures/power-1.light.webp)

*FIG. 1 — ONE CELL STEPPED PAST THRESHOLD*

BMS validation testing confirms that a battery management system measures cell voltages, temperatures and pack current within specification, balances cells, trips each protection at the right threshold and delay, and reports it all correctly over CAN. To automate the bench, drive a cell simulator and SCPI instruments from one script and read the BMS over CAN.

This guide builds that bench in Python with [python-can](https://python-can.readthedocs.io/en/stable/) and [cantools](https://cantools.readthedocs.io/en/latest/) for CAN, PyVISA for SCPI instruments, and a driver class for the cell simulator, and shows where IEC 62619 fits. For VISA resource strings, error queues and timeouts, start with [SCPI instrument automation with Python](https://galoislabs.ai/blog/scpi-automation-python).

## What does BMS validation testing cover?

A BMS is a measurement system with the authority to disconnect a battery, so validation checks both halves: the numbers it reports, and when it acts on them. On a hardware-in-the-loop bench, a cell simulator stands in for the cells, so any state is available on demand, including a cell above its overvoltage limit.

| Area            | Stimulus                              | Pass criterion                                             |
| --------------- | ------------------------------------- | ---------------------------------------------------------- |
| Cell voltage    | Simulator setpoints across the range  | BMS report matches a reference DMM within spec, every cell |
| Temperature     | Emulated thermistor resistance        | BMS report within spec across the range                    |
| Current         | Current through the BMS current path  | BMS report within spec, both directions                    |
| Balancing       | One cell offset above the rest        | Starts, stops and bleeds as specified                      |
| Protection      | One input stepped past its limit      | Threshold, delay and release within spec                   |
| CAN reporting   | Normal operation                      | Frame timing and status match the DBC                      |
| Fault injection | Sense-line, sensor and message faults | Detected in time, correct safe state                       |

Run these on simulated cells for every firmware build that touches measurement or protection, then repeat the protection checks on real packs. [EVT, DVT and PVT testing](https://galoislabs.ai/blog/evt-dvt-pvt-testing) covers how that split shifts toward production.

## How does IEC 62619 frame BMS safety tests?

[IEC 62619:2022](https://webstore.iec.ch/publication/64073) "specifies requirements and tests for the safe operation of secondary lithium cells and batteries used in industrial applications, including stationary applications." Its scope lists stationary uses such as telecom, UPS and energy storage, and motive uses such as forklifts, AGVs, railway and marine vehicles, "with the exception of road vehicles". Where an IEC standard for a special application conflicts with it, that standard takes precedence, for example the IEC 62660 series for road vehicles. It covers first-life cells and batteries only.

The table of contents shows how the tests divide (Source: [IEC 62619:2022 preview](https://assets.vde-verlag.de/iec-normen/preview-pdf/info_iec62619%7Bed2.0.CMV%7Den.pdf)):

- **Clause 7, specific requirements and tests:** misuse tests, mostly on cells or cell blocks (external short circuit, impact, drop, thermal abuse, overcharge, forced discharge), plus an internal short-circuit test on cells and a propagation test on the battery system.
- **Clause 8, battery system safety (considering functional safety):** subclause 8.2 covers the battery management system, with 8.2.2 overcharge control of voltage, 8.2.3 overcharge control of current and 8.2.4 overheating control, each at battery-system level.
- **Annex A (normative), the operating region of cells for safe use:** charging voltage, temperature ranges and discharging conditions.

Three consequences for a bench team:

1. **Clause 7 is destructive testing**, mostly of cells, for a lab. Clause 8 is where the BMS is the protection, and a bench can rehearse those reactions against simulated cells on every firmware build, before a pack goes to a test lab.
2. **The clause titles are not a test plan.** Your BMS requirements likely also define undervoltage, discharge overcurrent, short-circuit and low-temperature charge limits. Test those too.
3. **Thresholds trace to Annex A.** Record which cell datasheet and operating region each BMS limit came from.

None of this is certification. IEC's foreword states that "IEC itself does not provide any attestation of conformity"; independent certification bodies do that. A bench produces engineering evidence you can review, rerun and share with a test lab.

## What equipment does a BMS test bench need?

| Function      | Instrument                                    | What to check                            |
| ------------- | --------------------------------------------- | ---------------------------------------- |
| Cell voltages | Cell simulator, one isolated channel per cell | Accuracy, sink current, stack isolation  |
| Temperatures  | Programmable resistors on thermistor inputs   | Resolution at the hot end                |
| Pack current  | DC supply and electronic load                 | Current and power at the OC thresholds   |
| Reference     | DMM with a multiplexer                        | Several times better than the tolerance  |
| BMS data      | CAN interface (SocketCAN, PCAN, Vector)       | Bit rate and the BMS's DBC file          |
| Faults        | Relay or fault-insertion switching            | No added resistance in sense lines       |
| Fast timing   | Oscilloscope on FET gate or contactor coil    | Reactions faster than a CAN frame period |

The cell simulator is the center of the bench. A row of bench supplies is not equivalent: a single-quadrant supply cannot sink current, so it cannot hold its voltage when active balancing or a charge current pushes current into the cell, and the channels must float on one another up to the full stack voltage. Whether yours takes SCPI, a vendor library or CAN frames, hide it behind a driver class with methods like `set_voltage(cell, volts)` and `set_all(volts)`, so tests never depend on the transport.

Treat the simulator's readback as a convenience. Accuracy needs an independent reference at the BMS connector, after wiring. For pack current, real current through the shunt tests the shunt and its layout; emulating the shunt's millivolt signal is cheaper but skips it. Do the first at least once per hardware revision.

## How do I read BMS data over CAN in Python?

The BMS team's DBC file defines every frame and signal: ID, bit layout, scale, offset, unit and cycle time. cantools decodes frames with it, and python-can receives them. On Linux, bring the interface up at the BMS's bit rate with `ip link`, as shown on [python-can's SocketCAN page](https://python-can.readthedocs.io/en/stable/interfaces/socketcan.html). This listener keeps the latest value of every signal and the arrival time of every frame:

```python title="bench/bms_can.py"
import threading
import time
from collections import defaultdict

import can
import cantools


class BmsMonitor(can.Listener):
    """Latest value and receive time of every DBC signal, plus frame arrival times."""

    def __init__(self, dbc_path: str):
        self.db = cantools.database.load_file(dbc_path)
        self.frame_ids = {m.frame_id for m in self.db.messages}
        self.latest: dict[str, tuple[float, float]] = {}
        self.arrivals: dict[int, list[float]] = defaultdict(list)
        self.changed = threading.Condition()

    def on_message_received(self, msg: can.Message) -> None:
        if msg.is_error_frame or msg.arbitration_id not in self.frame_ids:
            return
        try:
            signals = self.db.decode_message(msg.arbitration_id, msg.data, decode_choices=False)
        except cantools.database.DecodeError:
            return  # an unhandled exception here would end the Notifier's receive thread
        with self.changed:
            self.arrivals[msg.arbitration_id].append(msg.timestamp)
            for name, value in signals.items():
                self.latest[name] = (float(value), msg.timestamp)
            self.changed.notify_all()

    def wait_for(self, signal: str, ok, after: float, timeout: float) -> tuple[float, float]:
        """Wait until the latest frame stamped later than `after` has ok(value) true."""
        deadline = time.monotonic() + timeout
        with self.changed:
            while True:
                value, stamp = self.latest.get(signal, (None, 0.0))
                if value is not None and stamp > after and ok(value):
                    return value, stamp
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise TimeoutError(f"{signal}: nothing qualifying in {timeout} s (last {value})")
                self.changed.wait(remaining)
```

Attach it with `can.Notifier(bus, [monitor])` on a bus opened as `can.Bus(interface="socketcan", channel="can0")`; the [Notifier](https://python-can.readthedocs.io/en/stable/notifier.html) calls `on_message_received` from its own thread. Three details matter:

- **`decode_choices=False`** keeps enumerated signals as numbers. By default, cantools converts them to choice strings, so a fault flag would decode as a DBC label rather than `1`.
- **`msg.timestamp`** is the receive time in seconds since the epoch, taken from the kernel on SocketCAN, so it compares directly with `time.time()` at a stimulus change.
- **`after` prevents stale passes.** A BMS filters its measurements, so the first frame after a change can carry an older sample. Waiting for a frame stamped after the change plus the BMS's settling time keeps a test from passing on old data.

## How do I test cell voltage and temperature measurement accuracy?

Step each cell through its operating range, read the voltage at the BMS sense pins with a reference DMM, wait for a fresh BMS report, and record the difference. The reference here is a Keysight 34461A, where `MEAS:VOLT:DC?` returns one autoranged reading at 10 power-line cycles ([Truevolt guide](https://www.keysight.com/us/en/assets/9018-03876/service-manuals/9018-03876.pdf)).

```python title="accuracy.py"
import time

import can
import pyvisa

from bench.bms_can import BmsMonitor
from bench.cells import CellSimulator  # your simulator's driver class
from bench.switching import Mux        # routes one cell's sense pins to the DMM

SETPOINTS_V = (2.80, 3.20, 3.60, 4.00, 4.20)  # example; span the cell's operating range
NEIGHBOR_V = 3.40                             # differs from every setpoint on purpose
LIMIT_MV = 5.0                                # example; take it from the BMS spec
SETTLE_S = 0.5                                # BMS filter settling time, from its design

bus = can.Bus(interface="socketcan", channel="can0")
bms = BmsMonitor("bms.dbc")
notifier = can.Notifier(bus, [bms])

rm = pyvisa.ResourceManager()
dmm = rm.open_resource("TCPIP0::192.168.1.50::hislip0::INSTR",
                       read_termination="\n", write_termination="\n", timeout=10_000)
cells, mux = CellSimulator(rm), Mux(rm)

rows = []
try:
    for cell in range(1, 17):
        for volts in SETPOINTS_V:
            cells.set_all(NEIGHBOR_V)
            cells.set_voltage(cell, volts)
            changed = time.time()
            mux.select(cell)
            ref = float(dmm.query("MEAS:VOLT:DC?"))
            bms_v, _ = bms.wait_for(f"Cell{cell:02d}_V", lambda v: True,
                                    after=changed + SETTLE_S, timeout=5.0)
            error_mv = (bms_v - ref) * 1e3
            rows.append({"cell": cell, "set_v": volts, "ref_v": ref, "bms_v": bms_v,
                         "error_mv": round(error_mv, 2), "pass": abs(error_mv) <= LIMIT_MV})
finally:
    notifier.stop()
    bus.shutdown()
```

Signal names such as `Cell07_V` come from your DBC. Two choices are deliberate. **Neighbors differ from the cell under test**, because two swapped sense wires read correctly while all cells sit at the same voltage. **Each row keeps the numbers beside the verdict**, so results can be judged again when a tolerance tightens.

Temperature follows the same loop with a programmable resistor on each thermistor input, set to the thermistor's resistance at the target temperature. TDK's [NTC thermistor technical information](https://www.tdk-electronics.tdk.com/download/531116/19643b7ea798d7c4670db0bc3f3e2a47/pdf-general-technical-information.pdf) gives the exponential approximation. Its example part, the B57861S0103F045, has a rated resistance of 10 kΩ and a B value of 3988 K; the defaults below use those values with a 25 °C rated temperature, the point most TDK specifications use:

```python title="bench/ntc.py"
import math


def ntc_ohms(temp_c: float, r25: float = 10_000.0, beta: float = 3988.0) -> float:
    """Beta approximation R_T = R_25 * exp(B * (1/T - 1/T_25)), temperatures in kelvin."""
    return r25 * math.exp(beta * (1 / (temp_c + 273.15) - 1 / 298.15))
```

For that part, the approximation gives about 2.45 kΩ at 60 °C, changing by roughly 88 Ω per degree, and about 108 kΩ at -20 °C, so size the programmable resistor for both ends. TDK notes that the exponential relation suits only a restricted range around the rated temperature; near a protection threshold, use the manufacturer's R/T table instead.

## How do I test OV, UV, OC and OT protection thresholds?

Every protection has three numbers to verify: the trip threshold, the delay before it acts, and the release point. Find the threshold with a step search, time the delay with one step past it, and find the release by stepping back.

```python title="bench/protection.py"
import time


def steps(start: float, stop: float, step: float) -> list[float]:
    """Inclusive values from start to stop; use a negative step for falling ramps."""
    n = round((stop - start) / step)
    return [round(start + i * step, 6) for i in range(n + 1)]


def find_trip(apply, values, bms, flag: str, dwell_s: float) -> float:
    """Apply each value in turn; return the first one that makes the BMS set `flag`."""
    for value in values:
        apply(value)
        changed = time.time()
        try:
            bms.wait_for(flag, lambda f: f == 1, after=changed, timeout=dwell_s)
            return value
        except TimeoutError:
            continue
    raise AssertionError(f"{flag} never set between {values[0]} and {values[-1]}")


def trip_delay(apply, below, above, bms, flag: str, timeout_s: float) -> float:
    """Seconds from a step past the threshold to the first frame reporting the trip."""
    apply(below)
    bms.wait_for(flag, lambda f: f == 0, after=time.time(), timeout=timeout_s)
    started = time.time()
    apply(above)
    _, stamp = bms.wait_for(flag, lambda f: f == 1, after=started, timeout=timeout_s)
    return stamp - started
```

The same two functions cover all four protections; only the stimulus changes. The windows below are examples around a hypothetical spec of 4.25 V overvoltage, 2.50 V undervoltage, 60 °C overtemperature and 30 A discharge overcurrent:

```python title="protection_tests.py"
from bench.ntc import ntc_ohms
from bench.protection import find_trip, steps, trip_delay

# cells, bms and rm are set up as in accuracy.py; therm drives the thermistor-input resistors
set_cell7 = lambda v: cells.set_voltage(7, v)

ov = find_trip(set_cell7, steps(4.20, 4.30, 0.002), bms, "OV_Flag", dwell_s=1.5)
uv = find_trip(set_cell7, steps(2.60, 2.40, -0.002), bms, "UV_Flag", dwell_s=1.5)
ov_delay = trip_delay(set_cell7, 4.20, 4.30, bms, "OV_Flag", timeout_s=5.0)
ot = find_trip(lambda c: therm.set_resistance(3, ntc_ohms(c)),
               steps(55, 65, 0.25), bms, "OT_Flag", dwell_s=3.0)

# Clear the OV and OT conditions; an active fault can open the path the OC test needs
cells.set_all(3.60)
therm.set_resistance(3, ntc_ohms(25))

# Discharge overcurrent: a Rigol DL3031 electronic load in constant current
load = rm.open_resource("TCPIP0::192.168.1.60::INSTR",
                        read_termination="\n", write_termination="\n")
load.write(":SOUR:FUNC CURR")
load.write(":SOUR:CURR:RANG 60")  # high range
load.write(":SOUR:CURR 0")
load.write(":SOUR:INP ON")
try:
    oc = find_trip(lambda a: load.write(f":SOUR:CURR {a}"),
                   steps(25, 35, 0.25), bms, "OC_Dsg_Flag", dwell_s=2.0)
    amps_after = float(load.query(":MEAS:CURR?"))  # near zero once the BMS opens the path
finally:
    load.write(":SOUR:INP OFF")
```

Four rules keep the numbers honest:

- **Dwell longer than the detection delay.** Otherwise the trip lands during a later step and the search reports a threshold that is too high.
- **Step smaller than the tolerance.** A 2 mV step resolves a threshold specified to ±10 mV; a 20 mV step does not.
- **Know the timing resolution.** `trip_delay` measures to the first status frame after the trip, so it resolves one frame period plus the simulator's settling time. For short-circuit reactions in microseconds, use an oscilloscope on the FET gate or contactor coil.
- **Stay inside the load's ratings.** [Rigol's DL3000 programming guide](https://www.rigol.com/dam/global/downloads/brochures/en/program-guide/dc-load/DL3000_ProgrammingGuide_EN.pdf) rates the DL3031 at 150 V, 60 A and 350 W, and warns that a CC setting above what the source can deliver short-circuits it. Feed the current path from a low-voltage supply, and keep input-off in a `finally` block. [Electronic load automation](https://galoislabs.ai/blog/electronic-load-automation) covers loads in depth.

After a trip, step back to find the release point and confirm the hysteresis. If the spec says a fault latches until a clear command or power cycle, a fault that clears itself fails the test, and a latched flag must be cleared before the next search, or `find_trip` returns on its first step. Repeat on every cell, because each has its own measurement channel.

## How do I verify cell balancing?

Set all cells equal, raise one by more than the balancing start threshold, and check four things:

1. The BMS flags balancing for that cell and no other.
2. That simulator channel supplies the expected bleed current, roughly the cell voltage over the bleed resistance for passive balancing. With active balancing, the channels receiving charge sink current.
3. Balancing stops below the stop threshold. A simulated cell does not discharge on its own, so step its voltage down to emulate that.
4. The cell's reported voltage stays right while balancing runs, since bleed current through sense-line resistance shifts the reading. Compare readings with balancing on and off.

Add one long run with several cells balancing at once: bleed resistors heat the board, and thermal derating shows up there.

## How do I check CAN reporting against the DBC?

The accuracy tests already check scaling. What remains is timing and status: every periodic frame should arrive at its DBC cycle time. cantools sets `cycle_time` from the DBC's `GenMsgCycleTime` attribute, falling back to the attribute's default, and returns `None` when neither is set ([cantools DBC loader](https://github.com/cantools/cantools/blob/master/src/cantools/database/can/formats/dbc/dbc_loader.py)). The check assumes milliseconds, so confirm the unit in your DBC. Drives commanded over vendor CAN are decoded the same way; see [motor drive test automation](https://galoislabs.ai/blog/motor-drive-test-automation).

```python title="bench/can_timing.py"
import statistics


def check_cycle_times(bms, tolerance: float = 0.10) -> list[str]:
    """Compare measured frame periods with each message's DBC cycle time."""
    problems = []
    for message in bms.db.messages:
        if message.cycle_time is None:
            continue  # event-driven, or no cycle time in the DBC
        stamps = bms.arrivals.get(message.frame_id, [])
        if len(stamps) < 2:
            problems.append(f"{message.name}: {len(stamps)} frames received")
            continue
        periods_ms = [(b - a) * 1e3 for a, b in zip(stamps, stamps[1:])]
        worst = max(abs(p - message.cycle_time) for p in periods_ms)
        if worst > tolerance * message.cycle_time:
            problems.append(f"{message.name}: median {statistics.median(periods_ms):.1f} ms, "
                            f"worst deviation {worst:.1f} ms against {message.cycle_time} ms")
    return problems
```

Skip messages other nodes send, such as a charger's requests. Beyond timing, check what the BMS reports when a measurement is invalid (a defined "not available" value, not a plausible number), that alive counters and checksums advance if the DBC defines them, and what the BMS does when the host's messages stop arriving.

## How do I inject faults into a BMS?

Fault injection checks diagnostics. Each fault needs a switching path and an expected reaction from your specification or FMEA.

| Fault                      | How to inject it                           | What to check                                  |
| -------------------------- | ------------------------------------------ | ---------------------------------------------- |
| Open cell sense wire       | Relay in series with a sense line          | Open-wire diagnostic; no false OV or UV nearby |
| Shorted sense lines        | Relay across two sense lines               | Detection, and which cells are flagged         |
| Thermistor open or shorted | Relay in series or across                  | Sensor fault, not a temperature                |
| Current sensor fault       | Shunt signal out of range                  | Plausibility check flags it                    |
| Lost host messages         | Stop sending the host's frames             | Safe state after the timeout                   |
| Supply brownout            | Programmable supply on the BMS logic input | Reset behavior, retained fault memory          |

An open NTC thermistor reads as extreme cold and a short as extreme heat. A BMS that reports either as a temperature has a diagnostic gap that normal-range testing never exercises. Assert detection time, the reported fault, the reaction, and how it clears. Supply brownouts follow the same rule. For a BMS on a 12 V or 24 V road-vehicle supply, [ISO 16750-2 electrical testing](https://galoislabs.ai/blog/iso-16750-2-electrical-testing) covers the voltage-drop and reset-behavior tests.

## How to run BMS validation tests in Galois with Évariste

Évariste, the agent in the Galois platform, does the same work on the same bench. Open it from the app sidebar (Ctrl+Shift+E) beside the project; [AI test automation for hardware benches](https://galoislabs.ai/blog/ai-test-automation-hardware) explains its sequences and profiles.

**Instruments.** Ask "List connected instruments" and check the bench appears on your team's edge, with the BMS read through the profile `dbc2galois` built from `bms.dbc`. If the cell simulator has no profile, upload its programming manual; Évariste generates one and, once you have reviewed it, deploys it to the edge and binds it to the instrument. Check each channel's voltage range, and that the simulator's output enable and the load's input-on are flagged dangerous, so sending either directly needs your confirmation.

**Objective.** State the checks with the code's numbers:

> Create a BMS validation sequence for the 16-cell BMS on can0. Accuracy: with the other cells at 3.40 V, set each cell to 2.80, 3.20, 3.60, 4.00 and 4.20 V, wait 0.5 s, then record it on the 34461A through the mux and on the BMS; they may differ by 5 mV at most. Overvoltage on cell 7, spec 4.25 V ±10 mV: step 4.20 to 4.30 V in 2 mV steps with a 1.5 s dwell, reading OV_Flag at each; it must read 0 at 4.238 V and 1 at 4.260 V. For the delay, from 4.20 V with OV_Flag at 0, set 4.30 V and read OV_Flag back to back for 5 s. UV on cell 7 at 2.50 V: 2.60 down to 2.40 V, same steps and dwell. OT at 60 °C on thermistor 3, a B57861S0103F045 (10 kΩ, B 3988 K): 55 to 65 °C in 0.25 °C steps, 3 s dwell. Then all cells to 3.60 V and thermistor 3 to 25 °C. Discharge OC at 30 A on the DL3031, CC on the 60 A range, input on at 0 A: 25 to 35 A in 0.25 A steps, 2 s dwell. Input off at the end.

**Draft.** Évariste writes each sweep out one setpoint per step, calling named profile commands, with limits only where you gave one; `measure` steps record without asserting:

```yaml title="bms_validation.yaml (excerpt)"
name: "BMS validation, 16 cells"
steps:
  # accuracy steps, then cell 7 stepped up from 4.200 V: set, wait, read OV_Flag
  - name: "Cell 7 to 4.238 V"
    type: action
    config:
      instrument_id: "cells"
      command_name: "set_cell_voltage"
      parameters: { cell: "7", voltage: "4.238" }

  - name: "Dwell past the OV detection delay"
    type: wait
    config: { duration_ms: 1500 }

  - name: "OV_Flag clear at 4.238 V (below 4.25 V - 10 mV)"
    type: numeric_limit
    config:
      instrument_id: "bms"
      command_name: "get_ov_flag"
      low_limit: 0
      high_limit: 0
      comparison: "GELE"

  # 4.240 to 4.258 V: set, wait, then a measure step records the flag
  - name: "OV_Flag at 4.250 V"
    type: measure
    config:
      instrument_id: "bms"
      command_name: "get_ov_flag"

  # 4.260 V: set, wait, then the flag must read 1; on to 4.300 V
  - name: "OV_Flag set at 4.260 V (4.25 V + 10 mV)"
    type: numeric_limit
    config:
      instrument_id: "bms"
      command_name: "get_ov_flag"
      low_limit: 1
      high_limit: 1
      comparison: "GELE"
```

**Review and approval.** No run starts until an engineer approves the draft. Check it against the rules above: dwell longer than the detection delay, steps smaller than the tolerance, thermistor resistances against TDK's R/T table near 60 °C, a fault cleared before each search if faults latch, and load input off last. A step checks one reading against fixed limits, so a 5 mV window around each setpoint judges the BMS against the simulator's setpoint, not the DMM; keep both accuracy readings as `measure` steps. [How to review an AI-generated test plan](https://galoislabs.ai/blog/review-ai-generated-test-plan) covers the rest. Edit in conversation or the sequence builder; every change is a new version with a diff, and an edit after approval needs re-approval. If you stop a run partway, check the input is off.

**Run, results and report.** Start the run; galois-edge executes it, and Monitor shows channels live. Every step records its measured value, limits, pass or fail, raw command and response, instrument, operator, DUT serial and timestamps. Ask Évariste which steps failed or passed close to a limit, which accuracy points differ from the 34461A by more than 5 mV, where OV_Flag first read 1 (the trip voltage `find_trip` returns), and how long after the 4.30 V step it first read 1, from the step timestamps. Check each figure against the recorded steps; the delay resolution is the spacing of the back-to-back reads. After a firmware change, ask it to compare runs. Then ask it to "Generate a test report from the last run" and edit it in the report editor.

You no longer write or maintain the CAN listener thread, simulator driver class, search loops, timeout handling, result rows or a report script. The limits from the BMS specification and cell datasheet, review and approval, wiring, and safety around energized cells stay with you.

| Step            | Code path (this guide)        | Galois with Évariste                        |
| --------------- | ----------------------------- | ------------------------------------------- |
| Read the BMS    | `BmsMonitor` with the DBC     | DBC profile, typed commands                 |
| Drive the cells | `CellSimulator` class         | Profile generated from the manual           |
| Accuracy        | `accuracy.py` with `wait_for` | DMM and BMS readings per point              |
| Temperature     | `ntc_ohms`                    | Resistances checked in review               |
| Thresholds      | `find_trip` over `steps`      | One step per setpoint, edge limits          |
| Delay           | `trip_delay`                  | Back-to-back reads, step timestamps         |
| Load safety     | Input off in `finally`        | Input-off step, checked in review           |
| Interpret       | Your analysis of `rows`       | Failed and near-limit steps; run comparison |
| Report          | Your own script               | Generated report, report editor             |

## How does Galois run a BMS bench?

Galois is agent-driven test engineering for hardware teams: agents generate tests and instrument drivers, run them on real benches through the open-source galois-edge daemon, and turn the results into reports and a shared engineering record.

For a BMS bench, the daemon puts CAN and SCPI behind one interface. It [discovers instruments on every enabled bus](https://docs.galoislabs.ai/guides/connecting-instruments/), including CAN through python-can. The `dbc2galois` script converts the BMS's DBC file into a profile, and the daemon registers its CAN messages as typed commands that return timestamped values. SCPI instruments such as the DMM and electronic load are identified with `*IDN?` and matched against the profiles the daemon has loaded, and instruments without a profile still accept raw SCPI. Galois ships 573 instrument profiles across 135 manufacturers in its [instrument library](https://galoislabs.ai/instruments). Commands flagged `is_dangerous` reach MCP clients as a destructive hint, so a client can ask for confirmation before an agent energizes hardware. The [Évariste walkthrough above](https://galoislabs.ai/blog/bms-validation-testing#how-to-run-bms-validation-tests-in-galois-with-évariste) runs this guide's checks on that same stack.

The [platform](https://galoislabs.ai/product) keeps the per-step run record described above; [hardware test traceability](https://galoislabs.ai/blog/hardware-test-traceability) covers why it matters. [Can an LLM safely drive lab instruments?](https://galoislabs.ai/blog/llm-instrument-safety) covers the guardrails around energized hardware.

The platform also runs as a dedicated single-tenant cloud or fully on-prem and air-gapped ([deployment options](https://galoislabs.ai/deployment)). The record supports a certification body's review; it does not replace one. To try the daemon, start with the [quickstart](https://docs.galoislabs.ai/getting-started/quickstart/).

## Frequently asked questions

### What is BMS validation testing?

BMS validation testing checks that a battery management system measures cell voltages, temperatures and pack current within specification, balances cells, trips each protection (overvoltage, undervoltage, overcurrent, overtemperature) at the specified threshold and delay, and reports all of it correctly over CAN. Most of it runs on a hardware-in-the-loop bench where a cell simulator stands in for the cells.

### Why use a cell simulator instead of bench power supplies?

A BMS bench needs one isolated channel per cell, stacked in series up to the full pack voltage, and each channel has to sink current as well as source it, so it holds its voltage when active balancing or a charge current pushes current into the cell. A single-quadrant bench supply sources current but cannot sink it. A cell simulator also lets you set any cell to any state on demand, including states that would be dangerous to create with real cells.

### Does IEC 62619 apply to my BMS?

IEC 62619:2022 covers secondary lithium cells and batteries for industrial applications: stationary uses such as telecom, UPS and energy storage, and motive uses such as forklifts, AGVs, railway and marine vehicles, but not road vehicles. Where an IEC standard for a special application conflicts with it, such as the IEC 62660 series for road vehicles, that standard takes precedence. Its clause 8.2 sets requirements for the BMS, including overcharge control of voltage, overcharge control of current and overheating control at battery-system level. An automated bench does not certify a battery to it: IEC states that it does not provide any attestation of conformity, and independent certification bodies provide conformity assessment. The bench produces engineering evidence you can review, rerun after a firmware change and share with a test lab.

### How do you test BMS overvoltage protection?

Raise one simulated cell in steps smaller than the threshold tolerance, holding each step longer than the BMS's detection delay, until the BMS reports the fault. That gives the trip voltage. Then step from below the threshold to above it and time the reaction for the delay, and step back down to find the release point. Repeat for each cell, because each has its own measurement channel.

### Can I run BMS validation tests without writing Python?

Yes. Describe the checks to Évariste, the agent in the Galois platform, in plain English with the limits from your BMS specification. It drafts a versioned sequence that sets the cell simulator and reads the reference DMM and the BMS's CAN signals, and the galois-edge daemon runs it on your bench. An engineer reviews and approves the draft before it runs, and every step's reading, limits and raw response are recorded for the report.
