Skip to content

How much energy did this cycle take?

One question, answered end to end: join two rates into one dense frame, multiply a voltage by a current, and integrate. The SDK carries the units the whole way — V and A become W, and W integrated over seconds becomes J — so the answer is labeled without a single unit string being typed.

You'll use: mixed-rate queries · ffill() / dropna() · SignalSeries operators · integrate() · where() · plot()

Run it

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

Copy the notebook below into power-energy-math.md, then:

zelos notebook run power-energy-math.md

The notebook

---
description: Align a mixed-rate join, multiply volts by amps, and integrate to joules.
requires-python: ">=3.10"
dependencies:
  - zelos-sdk[notebook]
params:
  window: "-2m"
  heavy_current: 25.0
---

How much energy did the pack move in the last couple of minutes? The answer is
the integral of volts times amps — and the bench gives you neither of those on
one clock. The converter reports its bus voltage and the pack its current once
a second; the inverter reports its own power ten times a second. Pull all
three and the join shows you exactly that.

```python
from zelos_sdk import connect

agent = connect()
frame = agent.query(
    [
        "bus0/DCDC_message/status.input_voltage",
        "bus0/BMS_message/status.pack_current",
        "bus1/inverter_status.battery_power",
    ],
    start=params.window,
).short_names()
frame
```

A query joins on time: one row per distinct timestamp, and a row carries a
null wherever a message had no sample at that instant. The 10 Hz inverter sets
the row rate, so the two 1 Hz columns are null nine rows out of ten — the
per-column non-null count says so.

Arithmetic tolerates that; integration does not. `integrate()` refuses a
series with gaps rather than inventing samples across them, and names the fix:

```python {.norun}
# frame["battery_power"].integrate()
# ValueError: 'bus1/inverter_status.battery_power' has N nulls; call .ffill()
#             for sample-and-hold or .dropna() before integrate()
```

## Align the rates

`ffill()` carries each column's last value forward — sample-and-hold, which is
what a slow sensor means anyway — and `dropna()` drops the leading rows that
had nothing to carry yet. Two calls, and every column is dense at the fastest
rate in the frame:

```python
aligned = frame.ffill().dropna()
aligned
```

## Volts times amps

No unit strings anywhere. `*` composes the units and renders `V·A` as `W`. It
composes the name too:
`bus0/DCDC_message/status.input_voltage × bus0/BMS_message/status.pack_current`,
which is right but long, so give the quantity the name you'd use out loud:

```python
power = (aligned["input_voltage"] * aligned["pack_current"]).rename("pack power")
power
```

## The answer

`integrate()` multiplies by seconds and renders `W·s` as `J`. It returns the
running total, so its last value — the largest, since the pack only draws — is
the energy the window covers:

```python
energy = power.integrate()
energy.max()
```

An aggregation is a `NamedScalar`: a float that still prints its unit, so the
answer arrives labeled `J` instead of as a bare number you have to annotate.

## Units cancel, and mismatched units refuse

Dividing the power back by the current gives you the voltage again, because
the exponents cancel before anything is rendered:

```python
(power / aligned["pack_current"]).unit
```

Adding two different units is a mistake the SDK will not commit for you:

```python {.norun}
# aligned["input_voltage"] + aligned["pack_current"]
# ValueError: cannot add 'V' and 'A'; convert first (pandas + pint)
#             or assert with .with_unit()
```

## Narrow the question

`where()` keeps the samples matching a condition and nulls the rest, so the
same series answers a narrower question — here, the average power only while
the pack is drawing hard:

```python
power.where(aligned["pack_current"] > params.heavy_current).mean()
```

## Chart the derivation

```python
power.plot()
```

The pattern generalizes: pull the raw electrical signals at whatever rates
they arrive, align them once, derive the quantity the question is about, and
let the units prove you composed it correctly.

What to notice

  • The join is per timestamp. Three signals at two rates produce one row per distinct time, with nulls where a message was silent — the frame's per-column non-null count is how you see it. ffill().dropna() is the alignment you write explicitly. See How a frame joins signals.
  • integrate() and derivative() refuse nulls rather than guessing across a gap, and the error names the two calls that fix it. That is why alignment comes first here and not as an afterthought.
  • No unit strings are ever written by hand. voltage * current renders W, integrate() renders J, and power / current cancels back to V. Adding mismatched units raises. See Compute on series.
  • A derived series names itself after the operands it came from, and every aggregation carries that name forward. rename() is there for when the composed name is longer than a legend or a table wants.
  • where() masks a regime without leaving the series world or disturbing the time axis; aggregations skip the nulls it leaves behind.
  • The demo signals are synthetic waveforms; the point is the algebra, which transfers unchanged to your real electrical signals.