Checks¶
A check is a rule. It states the invariant, evaluates it against real samples, and reports what it found — the reason and the sample that proves it. It never raises for a failure: a failing check reports a failure, so a suite runs to completion and tells you everything that is wrong in one pass.
The same call reads three ways: agent.check against live data, trace.check against a recording, and the check fixture inside pytest.
Check what you queried¶
The shortest path: query a window, then check a column of it.
from zelos_sdk import connect
agent = connect()
frame = agent.query("bus0/BMS_message/cells.*", start="-3m")
agent.check.that(frame["bus0/BMS_message/cells.cell_0"], ">", 3.0)
# 2026-09-04 17:06:58.855 cells.cell_0 (3.628 V) > 3 V always PASSED
A series operand carries its own window — the one you queried for — so the rule covers exactly the data you looked at, and there is no second time argument to keep in sync. Passing last= or start= alongside a series is an error for that reason, because the two could disagree.
A derived series works the same way: agent.check.that(voltage * current, "<", 5000.0) checks the power you computed.
Two signals compare directly, and the row shows the value each had at the deciding sample:
agent.check.that(frame["bus0/BMS_message/cells.cell_3"], "<=", frame["bus0/BMS_message/cells.cell_0"])
# 2026-09-04 22:22:00.311 cells.cell_3 (3.695 V) ≤ cells.cell_0 (3.684 V) always FAILED
Check a signal over its own window¶
When the rule should not depend on a query above it, name the signal and give the check its own window:
last= reads the duration grammar — "3m", timedelta(minutes=3), or 180. A window implies "always": every sample inside it must satisfy the rule. That is the shape of a limit — the rule above passes only when pack current never exceeds its 40 A threshold at any point in the last three minutes. With no window at all, a check reads the latest sample.
agent.signal(path) is a path-only handle: no catalog roundtrip, no SignalNotFound at construction. The agent resolves the path server-side at evaluation time, which is what makes it usable before a producer has emitted anything.
Read the result¶
result = agent.check.that(frame["bus0/BMS_message/cells.cell_3"], ">", 3.0)
result.passed # False
result.status # "fail" — "pass" / "fail" / "error"
result.reason # "predicate_false"
result.name # "bus0/BMS_message/cells.cell_3 > 3.0", or your name=
result.evidence # CheckEvidence(time_ns=1788541606840760000, signal="bus0/BMS_message/cells.cell_3", value=2.9898931980133057)
CheckResult is truthy when it passed, so if result: reads correctly, and it renders itself — as a row in a notebook, as a line in a terminal.
Evidence is the point. A failure names the first violating sample; a pass names the closest the data came to breaking the rule. Both are timestamped, so a red result points at the second to go and look at:
2026-09-04 17:06:58.855 cells.cell_0 (3.628 V) > 3 V always PASSED
2026-09-04 17:06:46.840 cells.cell_3 (2.99 V) > 3 V always FAILED
Counting instead of judging¶
count() returns how many samples matched. It never fails — it is a measurement, which is what you want for a threshold you are still calibrating:
breaches = agent.check.count(frame["bus0/BMS_message/cells.cell_3"], "<", 3.5)
breaches # 2026-09-04 17:27:23.400 cells.cell_3 < 3.5 V count · 93 PASSED
int(breaches) # 93
Mistakes raise¶
A wrong result is reported; a wrong call raises immediately, where you can fix it:
agent.check.that(frame["bus0/BMS_message/cells.cell_0"], ">", 3.0, last="3m")
# ValueError: the series already fixes the window; query a different window instead of passing last=/start=/end=
agent.check.that("bus0/BMS_message/cells.cell_0", ">", 3.0, last="3m")
# ValueError: last= needs a signal or series operand — a literal comparison has no samples to quantify over
That second one is worth internalizing: a bare string is a literal, not a signal. Use agent.signal(path) or a frame column when you mean the data.
In a notebook¶
A check renders itself as a row, so the cell shows the whole board. Checks never fail a run on their own: gate on one with assert agent.check.that(...), or collect them and end with raise_if_failed(), which raises AssertionError. See Notebooks as tests.
On a trace¶
trace.check and traces(...).check mirror agent.check exactly:
with agent.trace("/data/run.trz") as trace:
frame = trace.query("bus0/BMS_message/cells.*", start="start", end="+1m")
trace.check.that(frame["bus0/BMS_message/cells.cell_0"], ">", 3.0)
A check on a TraceSet returns one CheckResult per run, keyed by run label. The two duration temporals — temporal="within_duration" and temporal="for_duration" — read differently here: on live data they wait for data that has not arrived, and on a recording there is nothing to wait for, so duration_s becomes a window anchored at the end of the recording.
The fifteen operators¶
Every operator is a string, and the same set is accepted by the Python API, the JSON suite loader, and the CLI.
| Operator | Aliases | Rule | Example |
|---|---|---|---|
== |
=, is |
Exact equality | check.that(state, "==", "Normal") |
!= |
is not |
Inequality | check.that(state, "!=", "Fault") |
> |
Greater than | check.that(rpm, ">", 1000) |
|
>= |
Greater or equal | check.that(voltage, ">=", 3.0) |
|
< |
Less than | check.that(temp, "<", 80) |
|
<= |
Less or equal | check.that(current, "<=", 40) |
|
is_close |
is_close_to, is_around |
math.isclose |
check.that(v, "is_close", 5.0, abs_tol=0.1) |
is_approximately |
~= |
pytest.approx |
check.that(v, "~=", 5.0, rel_tol=0.01) |
is_divisible_by |
Modulo is zero (integers) | check.that(100, "is_divisible_by", 10) |
|
is_empty |
Length is zero | check.that(message, "is_empty") |
|
is_true |
Operand is True |
check.that(flag, "is_true") |
|
is_false |
Operand is False |
check.that(flag, "is_false") |
|
contains |
Substring is present | check.that(log, "contains", "ERROR") |
|
starts_with |
String prefix | check.that(msg, "starts_with", "OK") |
|
ends_with |
String suffix | check.that(name, "ends_with", ".csv") |
is_empty, is_true and is_false are unary: they take no right-hand side. op defaults to "is_true", so check.that(0 < value < 10) records any Python predicate over scalars as a check.
contains is a string operator: it asks whether the left side contains the right. To ask whether an element is in a collection, use membership.
Membership¶
in and not in (canonically is_in / is_not_in) test membership. Against a string right-hand side they mean substring containment; against a Python list they mean element-wise membership, and the check is evaluated in-process:
check.that(current_state, "in", ["IDLE", "RUNNING", "PAUSED"])
check.that("ERROR", "not in", valid_states)
check.that("H", "in", "Hello")
Tolerances¶
is_close(math.isclose):rel_toldefaults to1e-9,abs_tolto0.is_approximately(pytest.approx):rel_toldefaults to1e-6,abs_tolto1e-12,nan_oktoFalse.
Pass any subset as top-level kwargs on check.that; the rest stay at the stdlib defaults.
Ambiguity and producers¶
When the same path is recorded by several segments — an extension restarted, a remote agent reconnected, a fixture spun up a fresh source — the resolver picks the segment with the freshest data. Pass strict=True to make any multi-segment match an error instead, which is how a test asserts a unique-segment invariant:
The same path emitted by different producers is always an error; narrow with producers= to disambiguate. producers= also fans out: a multi-address list returns {address: CheckResult}.
JSON suites¶
A suite is the same lhs / op / rhs / temporal shape as a single check, in a file, run in one round-trip:
results = agent.check.suite("checks/live.json")
results.failed # only the ones that failed
results.raise_if_failed()
trace.check.suite("checks/trace.json", last="2s") is the recording mirror. The CLI runs the same files through zelos live check and zelos trace check.
In pytest¶
For hardware-in-the-loop tests, the check fixture provides the same vocabulary against in-process trace sources, plus the temporal words that wait on live data.
Enable console output to see the checkerboard:
At a glance¶
check.that(
lhs, # Signal, SignalSeries, literal, or TraceSourceCacheLastField
op="is_true", # operator string
rhs=None, # comparison value (omit for unary ops)
*,
last=None, # window; implies "always"
name=None, # what the row is called
temporal=None, # "latest" / "always" / "ever" / "never" /
# "count" / "for_duration" / "within_duration"
duration_s=None, # required for temporal="for_duration" / "within_duration"
interval_s=0.5, # polling cadence for duration temporals
nonblocking=False, # background thread for duration checks
rel_tol=None, abs_tol=None, nan_ok=False, # tolerance ops
)
- The checker fails the test immediately on failure by default (
fail_fast=True). - To evaluate every check and fail at the end, use
@check_config(fail_fast=False). - With
nonblocking=True, the duration check runs in a background thread; fixture teardown joins it before pytest reports. temporal="for_duration"/temporal="within_duration"requireduration_sand are mutually exclusive.
Live waits¶
temporal="within_duration" and temporal="for_duration", with duration_s=, are the two live waits. within_duration passes when the predicate becomes true inside the duration; for_duration when it stays true for it. Both anchor at now, so they take no window, and interval_s sets the polling cadence.
def test_reaches_target(motor, check):
motor.start()
check.that(motor.status.rpm, ">", 1900, temporal="within_duration", duration_s=0.5)
check.that(motor.status.current, "<", 50, temporal="for_duration", duration_s=2.0)
def test_within_duration(check, source):
system.start_async_operation()
check.that(
source.status.complete, "is_true",
temporal="within_duration", duration_s=5.0, interval_s=0.1,
)
# Non-blocking: runs in a background thread, joined at teardown.
check.that(
source.signal.value, ">", 100,
temporal="within_duration", duration_s=3.0, nonblocking=True,
)
perform_other_tests()
def test_for_duration(check, source):
check.that(
source.voltage.value, "is_close", 5.0,
temporal="for_duration", duration_s=10.0, interval_s=0.5, abs_tol=0.1,
)
Non-blocking and fail-fast¶
- With
nonblocking=True, the duration check runs in a background thread. The fixture tracks it and joins it during teardown, so a failure still flips the test result before pytest reports. - To aggregate failures across every check instead of raising on the first, use
@check_config(fail_fast=False). - Combining
nonblocking=Truewithfail_fast=Truemeans a failure may land while the test body continues; teardown surfaces it through pytest's delayed-fail hook.
Fixtures pattern¶
Define sources and events in fixtures, then consume fields in tests. The checker calls .get() on a TraceSourceCacheLastField at spec-build time and keeps the field's name for the row.
# conftest.py
import pytest
import zelos_sdk
@pytest.fixture(scope="module")
def motor():
source = zelos_sdk.TraceSourceCacheLast("motor")
source.add_event("status", [
zelos_sdk.TraceEventFieldMetadata("rpm", zelos_sdk.DataType.Float64),
zelos_sdk.TraceEventFieldMetadata("torque", zelos_sdk.DataType.Float64, "Nm"),
zelos_sdk.TraceEventFieldMetadata("temperature", zelos_sdk.DataType.Float64, "°C"),
])
yield source
# test_motor.py
def test_trace_integration(motor, check):
motor.status.log(rpm=2500.0, torque=42.0, temperature=75.5)
check.that(motor.status.rpm, "==", 2500)
check.that(motor.status.torque, ">", 30)
check.that(motor.status.temperature, "<", 80)
The checkerboard¶
Zelos Checkerboard
test_trace_integration
┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓
┃ time ┃ check ┃ window ┃ result ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩
│ 2026-09-04 17:26:56.979 │ status.rpm (2500) = 2500 │ latest │ PASSED │
│ 2026-09-04 17:26:56.979 │ status.torque (42) > 30 │ latest │ PASSED │
│ 2026-09-04 17:26:56.979 │ status.temperature (75.5) < 80 │ latest │ PASSED │
└─────────────────────────┴────────────────────────────────┴────────┴────────┘
One row per check: when it was decided (the evidence sample's time, or the check's own when it read none), the rule with the value it read, the window it covered, and the result.
Troubleshooting¶
No checkerboard in the output. Enable log capture:
The test stops at the first failure. That is fail_fast=True, the default. To run every check and fail at the end:
@check_config(fail_fast=False)
def test_all_of_them(check):
check.that(1, "==", 2) # fails, but the test continues
check.that(2, "==", 2) # still runs
API reference¶
Every signature, parameter and return type is in the Python reference.
What's next¶
-
Checks score every rule, one assertion gates the run, and the exit code is the contract.
-
The frames and series a check reads.
-
Capture
.trzfiles from a pytest run and link them in reports. -
Every class, method and parameter, generated from the package.