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: the result renders as a row with the count, and `int()` of
it is 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 near 2.8 V. That is 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 a CheckResult whose int() is how many samples matched, and it never fails a check. That is what makes it the right tool for a warning threshold you are still calibrating.
  • A failing check reports; an assertion fails the run. check.that() records its result and does not raise, so this notebook stays green either way. To make an out-of-spec reading fail the run with exit code 1, assert on the results or call raise_if_failed(), as the release gate does. 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.