Skip to content

BMS Daisy-Chain Signal Integrity

Daisy-chain integrity of a cell-monitoring module: isolation caps, wake and address, pulse amplitude on a jumper and a 3 m harness, frame loss vs common mode.

TofuPilotFunctional TestPythonTofuPilot FrameworkGitHub
BMS Daisy-Chain Signal Integrity

Introduction

Daisy-Chain Overview

A pack of 400 V is monitored by a chain of cell-monitoring modules, each floating at its own stack potential, each talking to the next over one isolated twisted pair. TI's BQ79616 family runs a bidirectional, half-duplex differential link at 2 MHz through 2.2 nF isolation capacitors or a pulse transformer, reclocked at every device; ADI's isoSPI does the same at 1 Mbps through a transformer with the drive current and the receive threshold both set by one resistor divider. Both links have a property that makes them the classic escape of a BMS production line: the defects that matter are analog margin defects, and they pass a protocol test on a 20 cm fixture jumper and fail on the pack harness.

The edge of a cell-monitoring PCBA: the two-pin daisy-chain connector with a twisted pair plugged in, four ceramic isolation capacitors in a row beside it, a chip further in.

The daisy-chain port of one module: the two-pin connector, the twisted pair, and the four series isolation capacitors that set how much of the 2 MHz pulse reaches the next module.

The numbers to build the test on are in TI SLVAEP4, the daisy-chain application note: data pulses of 250 ns ± 20 ns, tones of 1 µs ± 8 %, a good waveform above 1.8 V differential, and a bit that is not detected at all when its starting amplitude is below 1.6 V, whatever its ending amplitude. A wrong isolation capacitor (1 nF where 2.2 nF belongs), a missing common-mode choke, a transformer from a different reel on one side of the link, or on isoSPI a wrong IBIAS/ICMP divider that raises the threshold while leaving the drive alone: each one leaves the bench amplitude comfortably above 1.8 V and the pack amplitude below 1.6 V. A test that asks "did it communicate?" over a jumper answers yes to all of them.

Test Purpose

The procedure records one link fingerprint per module:

  • The four series isolation capacitors, measured on an LCR meter before the module is powered
  • Wake and auto-address over the jumper, the reclock delay and the idle common-mode voltage
  • Pulse amplitude (start and end of the worst double-bit) and pulse timing (data and tone) on the 20 cm jumper and again on a 3 m worst-case harness
  • Frame loss over 1000 broadcast reads on the long harness at -15, 0 and +15 V of common-mode offset
  • The fault summary before and after the stress

Differential pulse amplitude against harness length on a decaying curve: the 0.2 m jumper point at 2.88 V, the 3.0 m harness point at 2.29 V, the 1.8 V good line and the 1.6 V cliff below; the curve crosses the cliff near 7.5 m.

The mock module on its attenuation curve: 2.9 V on the jumper, 2.3 V on the 3 m harness, the cliff at 7.5 m. A module with a 1 nF capacitor sits on a curve half as high, and its jumper reading would still clear 1.8 V.

The framework mechanics on show are a component check in setup: before the DUT is powered, the same measurement recorded on two fixtures with the limit where it matters, limits copied from the application note, a multi-dimensional measurement with an integer aggregation validated == 0, JSON == on the fault summary before and after stress, and a teardown: that restores the fixture.

Equipment & Setup

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

  • A host bridge for the chain (a BQ79600 on a UART adapter, or an LTC6820 for isoSPI)
  • A relay-switched harness: a 20 cm jumper and a real 3 m twisted pair in the fixture
  • A differential probe and a digitiser at 1 GS/s or better on the module's COM pins
  • An isolated DC source in series with the harness return, for the common-mode offset
  • An LCR meter behind the probe head for the isolation capacitors
  • The Device Under Test (DUT): one cell-monitoring module with its production firmware
  • A TofuPilot Framework procedure to switch the fixtures, capture the margins and validate the limits
  • The TofuPilot Dashboard to trend the harness amplitude across modules, which is where a capacitor reel change shows first

Hardware Components

Bridge, Harness and Probe

The BQ79600-Q1 bridges a UART or SPI host to the daisy chain and supports mixed isolation on the two sides. The harness relays are the fixture's own; the 3 m pair is the worst case of the pack's inter-module runs, with the isolation network of the neighbouring module at its far end. Any differential probe with 1 GHz of bandwidth on a Keysight or Tektronix scope resolves a 250 ns pulse and its 20 ns window; the capture is a single-shot on the COMH pair during a broadcast read.

Daisy-chain test station: the cell-monitoring PCBA in a fixture on the left, a coiled twisted-pair harness in the middle, a small bridge box with a red pushbutton on the right.

A single-fixture station: the module in the fixture, the coiled 3 m harness the relays switch in for the margin phases, the bridge box at the host end.

Isolation Network

Capacitor isolation is the low-cost option: 2.2 nF rated at twice the working voltage, up to 5.6 nF; a common-mode choke (TDK ACT45B-510 or -101, Würth 744242471) is added for longer runs or noisier packs; a pulse transformer is the robust option, with the same part number on both sides of every link. The setup phase measures the four series capacitors on an LCR meter at 1 kHz through the probe head, before the chain is powered, because a wrong value is a component defect and a component check finds it in milliseconds; the margin phases then confirm what the capacitor did to the pulse.

Common-Mode Source

The receiver is designed to survive ±20 V of common mode between modules (inverter noise, bulk current injection); above that the ESD structures clamp. An isolated supply in series with the harness return applies ±15 V during the frame-loss count. A missing choke or a marginal capacitor shows as loss at ±15 V before it shows at 0 V.

Where the Limits Come From

No standard specifies a daisy-chain production test; ISO 11898 and its conformance plans cover CAN, not this link, and ISO 26262 asks that the safety-related communication be controlled in production without naming a voltage. The limits are the application note's:

TestLimitBasis
Isolation capacitors1.8 to 2.6 nF2.2 nF ± 10 % plus the meter
Data pulse width230 to 270 ns250 ns ± 20 ns
Tone pulse width0.92 to 1.08 µs1 µs ± 8 %
Pulse starting amplitude≥ 1.8 V, on the harnessgood waveform; the bit is lost below 1.6 V
Idle common mode2.21 to 2.76 VV(DCCM)
Reclock delay≤ 5 µs4 µs typical, 5 µs maximum per device
Frame loss at ±15 V0 of 1000±20 V survival

The amplitude limit is applied on the harness and only recorded on the jumper: every module passes on the jumper, so a limit there catches nothing. The 3 m figure is this pack's worst inter-module run; the fixture carries the real length, not a calculation.

Test Procedure

Overview

The procedure maps the test onto the framework's three stages. The capacitor check lives in setup: so the chain is never powered on a module with a wrong isolation network. Restoring the jumper and the common mode lives in teardown: so the next module starts from a known fixture.

  1. Setup: four isolation capacitors on the LCR meter.
  2. Main: wake, auto-address, reclock delay, idle common mode, fault summary, on the jumper.
  3. Main: pulse amplitude and timing on the jumper, then on the 3 m harness.
  4. Main: frame loss at -15, 0 and +15 V on the harness, fault summary after.
  5. Teardown: jumper back, common mode off.

Every metric validates against limits declared in procedure.yaml, and results stream to TofuPilot for trending.

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
isolation_caps.py
wake_address.py
pulse_margin.py
common_mode_sweep.py
release.py
plugs
chain_bench.py
utils
recipe.py
pyproject.toml
README.md

You can find the full source on GitHub. The ChainBench plug is a mock of the bridge, the harness relays, the probe, the common-mode source and the LCR meter together, synthesizing a healthy module with an attenuation of 0.72 dB/m at 2 MHz, so the procedure runs end-to-end without a bench or a module 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
name: BMS Daisy-Chain Signal Integrityversion: 0.1.0description: Daisy-chain communication integrity of a cell-monitoring module. Isolation capacitors measured before power, wake and auto-address with reclock delay, differential pulse amplitude and timing on a 20 cm jumper and on a 3 m worst-case harness, frame loss at three common-mode offsets.unit:  auto_identify: true  serial_number:    description: "Scan the module's data-matrix"    placeholder: "CMU-000000"    pattern: "^CMU-\\d{6}$"    default_value: "CMU-031742"  part_number:    default_value: "PCB-CMU-16S-R2"  batch_number:    default_value: "LOT-2026-37"plugs:  - name: Chain Bench    description: "Host bridge, switchable harness, differential probe and digitiser, common-mode source, LCR meter (mock, one plug per bench)"    python: plugs.chain_bench:ChainBench    key: benchsetup:  - name: Isolation Caps    key: isolation_caps    python: phases.isolation_caps    measurements:      - name: Cap COMH P        key: cap_comh_p_nf        unit: nF        validators:          - {operator: ">=", expected_value: 1.8}          - {operator: "<=", expected_value: 2.6}      - name: Cap COMH N        key: cap_comh_n_nf        unit: nF        validators:          - {operator: ">=", expected_value: 1.8}          - {operator: "<=", expected_value: 2.6}      - name: Cap COML P        key: cap_coml_p_nf        unit: nF        validators:          - {operator: ">=", expected_value: 1.8}          - {operator: "<=", expected_value: 2.6}      - name: Cap COML N        key: cap_coml_n_nf        unit: nF        validators:          - {operator: ">=", expected_value: 1.8}          - {operator: "<=", expected_value: 2.6}main:  - name: Wake Address    key: wake_address    python: phases.wake_address    measurements:      - name: Address        key: address        validators:          - {operator: "==", expected_value: 1}      - name: Reclock Delay        key: reclock_us        unit: µs        description: Per-device reclock, 4 us typical, 5 us maximum; a slow reclock on one module adds up over a 24-device stack.        validators:          - {operator: "<=", expected_value: 5.0}      - name: Idle Common Mode        key: idle_common_mode_v        unit: V        description: V(DCCM) 2.21 to 2.76 V; the COM pins rest at 2.5 V between packets.        validators:          - {operator: ">=", expected_value: 2.21}          - {operator: "<=", expected_value: 2.76}      - name: Faults        key: faults        validators:          - operator: "=="            expected_value:              comm: false              otp: false              sys: false  - name: Pulse Margin    key: pulse_margin    python: phases.pulse_margin    depends_on: [wake_address]    measurements:      - name: Jumper Start Amplitude        key: jumper_start_v        unit: V        validators:          - {operator: ">=", expected_value: 1.8}      - name: Jumper End Amplitude        key: jumper_end_v        unit: V      - name: Jumper Data Width        key: jumper_data_width_ns        unit: ns        validators:          - {operator: ">=", expected_value: 230.0}          - {operator: "<=", expected_value: 270.0}      - name: Jumper Tone Width        key: jumper_tone_width_us        unit: µs        validators:          - {operator: ">=", expected_value: 0.92}          - {operator: "<=", expected_value: 1.08}      - name: Harness Start Amplitude        key: harness_start_v        unit: V        description: "On the 3 m worst-case harness. Good above 1.8 V; the receiver drops the bit below 1.6 V starting amplitude. This is the number the jumper cannot give you."        validators:          - {operator: ">=", expected_value: 1.8}      - name: Harness End Amplitude        key: harness_end_v        unit: V        description: Ending amplitude of the worst double-bit; recorded for trending, the start amplitude carries the limit.      - name: Harness Data Width        key: harness_data_width_ns        unit: ns        validators:          - {operator: ">=", expected_value: 230.0}          - {operator: "<=", expected_value: 270.0}      - name: Harness Tone Width        key: harness_tone_width_us        unit: µs        validators:          - {operator: ">=", expected_value: 0.92}          - {operator: "<=", expected_value: 1.08}  - name: Common Mode Sweep    key: common_mode_sweep    python: phases.common_mode_sweep    depends_on: [pulse_margin]    timeout: 2m    measurements:      - name: Frame Loss        key: frame_loss        title: Frames lost of 1000 on the 3 m harness at each common-mode offset        x_axis:          legend: Common mode          unit: V        y_axis:          - legend: Lost frames            key: lost            aggregations:              - type: total                validators:                  - {operator: "==", expected_value: 0}      - name: Faults After Sweep        key: faults_after_sweep        validators:          - operator: "=="            expected_value:              comm: false              otp: false              sys: falseteardown:  - name: Release    key: release    python: phases.release

Framework features to notice:

  1. A component check before power. The setup phase measures four capacitors with the DUT unpowered; a wrong reel fails here with the value, not three phases later with a lost bit.
  2. Two fixtures, one limit. jumper_start_v and harness_start_v are the same capture on two harnesses; the limit lives on the harness one and the jumper one is recorded for the trend.
  3. Integer aggregation == 0. The frame-loss curve carries a total aggregation validated as an exact zero.
  4. Fault summary before and after. faults and faults_after_sweep compare the same object twice; a communication fault raised by the stress is caught even when every frame came back.
  5. teardown: restores the fixture. Jumper back and common mode off whatever the outcome.

Isolation Caps

The setup phase reads the four series capacitors through the probe head with the module unpowered and records the isolation type on the unit metadata:

phases/isolation_caps.py
from utils.recipe import ISOLATION_CAP_NFdef isolation_caps(measurements, bench, unit, log):    """Setup: the four series isolation capacitors measured on the LCR    meter through the probe head before the chain is powered. A 1 nF part    where 2.2 nF belongs passes every protocol test on the jumper and    fails at pack length; it is a component check, not a comms check."""    caps = {pin: bench.isolation_cap_nf(pin) for pin in ("comh_p", "comh_n", "coml_p", "coml_n")}    measurements.cap_comh_p_nf = caps["comh_p"]    measurements.cap_comh_n_nf = caps["comh_n"]    measurements.cap_coml_p_nf = caps["coml_p"]    measurements.cap_coml_n_nf = caps["coml_n"]    unit.metadata["isolation"] = "capacitive 2.2 nF"    log.info(f"Module {unit.serial_number}: isolation caps {caps} nF")

Wake Address

The bridge wakes the chain with the tone, auto-addresses, and the phase reads back the address the module took, the reclock delay, the idle common-mode voltage on the COM pins and the fault summary. This is the protocol-level test every station already does; here it is the gate for the margin phases, not the verdict:

phases/wake_address.py
from utils.recipe import JUMPER_Mdef wake_address(measurements, bench, log):    """Wake the chain over the jumper, auto-address, read the reclock    delay and the fault summary. The protocol-level test, and the one    every station already does."""    bench.set_harness(JUMPER_M)    bench.set_common_mode_v(0.0)    res = bench.wake_and_autoaddress()    measurements.address = res["address"]    measurements.reclock_us = res["reclock_us"]    measurements.idle_common_mode_v = bench.idle_common_mode_v()    measurements.faults = bench.fault_summary()    log.info(f"Address {res['address']}, reclock {res['reclock_us']:.2f} us, idle common mode {measurements.idle_common_mode_v} V")

Pulse Margin

The same single-shot capture on both harnesses: the differential probe on COMH during a broadcast read, the worst double-bit found (two adjacent pulses of opposite polarity, the second starting from where the first drooped), its starting and ending amplitude and its width, plus the width of a tone. The harness values carry the amplitude limit:

phases/pulse_margin.py
from utils.recipe import JUMPER_M, WORST_CASE_HARNESS_Mdef pulse_margin(measurements, bench, log):    """Differential pulse amplitude and timing on the jumper and on the    worst-case harness. The amplitude on the long harness is the number    that predicts field failures: below 1.6 V starting amplitude the    receiver drops the bit, whatever the protocol test on the bench said."""    for metres, key in ((JUMPER_M, "jumper"), (WORST_CASE_HARNESS_M, "harness")):        bench.set_harness(metres)        bench.wake_and_autoaddress()        pulse = bench.capture_data_pulse()        tone = bench.capture_tone()        setattr(measurements, f"{key}_start_v", pulse["start_v"])        setattr(measurements, f"{key}_end_v", pulse["end_v"])        setattr(measurements, f"{key}_data_width_ns", pulse["width_ns"])        setattr(measurements, f"{key}_tone_width_us", tone["width_us"])        log.info(f"{metres:.1f} m: pulse {pulse['start_v']:.2f} -> {pulse['end_v']:.2f} V, data {pulse['width_ns']:.0f} ns, tone {tone['width_us']:.3f} us")    bench.set_harness(JUMPER_M)

Two panels of the worst double-bit: on the 0.2 m jumper a positive pulse from 2.88 V drooping to 2.60 V then a negative one, on the 3.0 m harness the same shape from 2.29 V to 1.83 V; the 1.8 V good line and 1.6 V cliff drawn on both, a gray band marking the 250 ±20 ns window.

The double-bit on both fixtures. On the harness the second pulse starts at 1.83 V, above the 1.8 V line by 30 mV; that margin is what the trend across a batch watches, and it is invisible on the jumper.

Common Mode Sweep

On the 3 m harness, 1000 broadcast reads at -15 V, 0 V and +15 V of common-mode offset between the module and the bridge, the frames that came back without a valid CRC counted at each step:

phases/common_mode_sweep.py
from utils.recipe import COMMON_MODE_STEPS_V, FRAMES, WORST_CASE_HARNESS_Mdef common_mode_sweep(measurements, bench, log):    """Frame loss over 1000 broadcast reads on the worst-case harness at    three common-mode offsets. The receiver is specified to survive +-20 V    of common mode; a missing choke or a wrong cap shows as loss at +-15 V    before it shows at 0 V."""    bench.set_harness(WORST_CASE_HARNESS_M)    bench.wake_and_autoaddress()    losses = []    for cm in COMMON_MODE_STEPS_V:        bench.set_common_mode_v(cm)        lost = bench.frame_loss(FRAMES)        losses.append(lost)        log.info(f"Common mode {cm:+.0f} V: {lost} of {FRAMES} frames lost")    bench.set_common_mode_v(0.0)    bench.set_harness(0.2)    measurements.frame_loss.x_axis = COMMON_MODE_STEPS_V    measurements.frame_loss.y_axis.lost = losses    measurements.frame_loss.y_axis.lost.aggregations.total = int(sum(losses))    measurements.faults_after_sweep = bench.fault_summary()

Mock Plug

ChainBench stands in for five instruments because the pulse the probe sees depends on the harness the relays selected and the common mode the source applied. It synthesizes four capacitors between 2.18 and 2.26 nF, a 2.94 V drive attenuated at 0.72 dB/m, a double-bit droop of 0.28 V plus 0.06 V per metre, data pulses of 251 ns plus 1.8 ns per metre, tones of 1.003 µs, a 4.1 µs reclock, and zero frame loss while the amplitude margin over the 1.6 V cliff exceeds 0.3 V and the common mode stays inside ±20 V. Every method returns plain Python types because plug calls cross a JSON boundary; a measurement read back from measurements.<key> returns a proxy, so the phases keep locals for their log lines.

On a real bench, the class speaks the bridge's protocol over pyserial, drives the harness relays and the common-mode source, and pulls the single-shot capture from the scope over SCPI (:MEAS:VAMP, :MEAS:PWID on the differential channel). For an isoSPI link the same phases apply with the LTC6820's ~150 ns CS pulses and ~50 ns data pulses, and the divider on IBIAS/ICMP becomes the component checked in setup. Keep the worst-case harness in the fixture; it is the cheapest countermeasure to the whole defect class. The phases, measurements and limits stay the same.

Run your first test in minutes