Skip to content

Find the weak cell

The post-bench triage: eight cell voltages, one of them quietly dipping under the undervoltage floor once per discharge cycle. The demo generator plants this fault deliberately — the notebook finds it without being told where to look, then converts the eyeball diagnosis into a check that runs unattended.

You'll use: glob queries · describe() · frame.items() ranking · plot() · check.count() · check.that() on a series

Run it

zelos live demo --backfill 5m --duration 30m    # terminal 1: demo data

Copy the notebook below into find-the-weak-cell.md, then:

zelos notebook run find-the-weak-cell.md

The notebook

---
description: Rank a pack's cells and find the one that sags under load.
requires-python: ">=3.10"
dependencies:
  - zelos-sdk[notebook]
params:
  window: "-3m"
  warn_floor: 3.0
  hard_floor: 2.5
---

A battery pack is only as strong as its weakest cell. This notebook is the
triage you'd run after a bench session: pull every cell voltage, rank the
cells, spot the one that sags, and turn the eyeball diagnosis into a check
that can run unattended.

The demo generator plants this exact fault: one of the eight cells dips under
the 3.0 V undervoltage floor once per discharge cycle while the others stay
healthy. Let's find it, without being told which one it is.

```python
from zelos_sdk import connect

agent = connect()
frame = agent.query("bus0/BMS_message/cells.*", start=params.window)
frame
```

## Spread the pack out

`describe()` is the whole-pack summary — count, mean, standard deviation and
quartiles per column, with each column's unit on the first row. The healthy
cells cluster tightly; the weak one's minimum falls out of the pack:

```python
frame.short_names().describe()
```

## Name the culprit

Every column of a frame is a `SignalSeries`, so ranking is one `min()`:

```python
worst = min(frame.values(), key=lambda s: s.min())
worst
```

The series prints its path, unit, length, null count, time extent, and a
preview of its values — enough to confirm you grabbed the right column.

```python
worst.min()
```

An aggregation is a `NamedScalar`: a float that still knows what it measured
and what unit it is in, so the minimum reads as a voltage everywhere it lands.

## See the sag

All eight cells on one chart. `short_names()` puts `cell_0` in the legend
instead of the full path:

```python
frame.short_names().plot()
```

## How often did it breach?

Triage has two thresholds: a warning floor you want to know about, and a hard
floor that means "do not ship". `count()` answers the first — it measures
without judging, and returns the number:

```python
agent.check.count(worst, "<", params.warn_floor)
```

## Turn it into a check

The hard floor is the rule. Passing the *series* checks exactly what you just
queried and charted — the check inherits the frame's window, so there is no
second time argument to keep in sync:

```python
agent.check.that(worst, ">", params.hard_floor)
```

A check reports as a row carrying its result, the rule, and the evidence —
for a pass, the closest the data came to breaking it. The weak cell spends
the back of every discharge cycle under the warning floor, a few dozen
samples in three minutes, and bottoms out just above 2.8 V — under the
warning floor, over the hard one. That is the triage finding, and it is why
this notebook ends green.

Re-run this against your own pack by changing the query path — the notebook
is the procedure, the params are the knobs. For the unattended version that
fails CI when a cell sags, see the *release gate* example.

What to notice

  • describe() finds the fault before the chart does. The per-cell minimum separates the weak cell from the healthy cluster numerically; the chart then confirms it visually.
  • A series check inherits its window. check.that(worst, ">", floor) checks the samples you queried, over the range you queried them for; passing last= alongside a series is an error, because the two could disagree. See Check what you queried.
  • count() is a measurement, not an assertion: it returns how many samples matched, and never fails a run. That is what makes it the right tool for a warning threshold you are still calibrating.
  • A failing check fails the run. This notebook stays green because the weak cell clears the hard floor. Point a rule at something that really is out of spec and the run exits 1 — which is the whole idea behind the release gate; see Notebooks as tests.
  • Signals by path, series by column. agent.signal(path) is a path-only handle the agent resolves at evaluation time; frame[path] is the data you already have. Use the handle when you want a window the frame doesn't cover.
  • Swap the query path for your own pack and the procedure transfers unchanged — the two floors are the contract knobs.