Query Live Data¶
Everything the agent knows how to answer, in one API: what signals exist, what they were doing over a window, what they are right now, and what changed in between. The same calls work on a saved .trz file — see Open trace files.
connect() checks that something answers before it returns. With no argument it resolves ZELOS_AGENT_URL, then http://localhost:2300. Pass a target to be explicit:
For a long-running script or a pytest fixture, prefer the lazy Agent(target): its channel reconnects on its own and errors surface at the call site rather than at construction.
Browse the catalog¶
signals() returns a SignalCatalog of everything that produced data recently. It renders itself — path, type, unit, producer — so a bare catalog in a notebook cell is the listing:
A catalog is a sequence and a mapping:
catalog[0] # by position
catalog["bus0/BMS_message/status.pack_current"] # by path, raises SignalNotFound
catalog.get("maybe/missing.signal") # by path, None when absent
"bus0/BMS_message/cells.cell_0" in catalog
len(catalog)
match() filters by glob and returns another catalog; search() is the loose, human spelling that scores substrings across path, message and unit:
The glob grammar¶
One grammar, used everywhere a path is accepted — match(), query(), latest(), at(), window(), export():
| Pattern | Matches |
|---|---|
* |
any run of characters, including / and . |
? |
exactly one character |
bus0/* |
every signal on the bus0 source |
bus0/BMS_message/* |
every signal on every BMS_message event |
bus0/BMS_message/cells.* |
every field of the cells event |
*.cell_* |
every signal whose field name starts with cell_, on any source |
*/status.pack_current |
that field, whichever source carries it |
A pattern that matches nothing raises SignalNotFound in query(), latest(), at(), window() and export(), rather than quietly returning a frame with a column missing. match() is the exception: filtering a catalog down to nothing returns an empty catalog. In a query, wildcards expand against the signals active inside the window you asked for.
Producers¶
An agent can serve several producers. Every read takes producers=, and the default — None — covers every connected one:
agent.signals() # all producers
agent.signals(producers="192.168.1.50:2300") # one
agent.signals(producers=["a:2300", "b:2300"]) # several
lookback= bounds how recently a signal must have produced a sample to appear. It applies to signals() and latest(), the two calls that ask "what is live right now"; a query is bounded by its own window instead.
Segments¶
A segment is one contiguous run of data from one producer. segments() is how you see where the gaps are:
Time and duration grammar¶
Two grammars, and the sign tells them apart.
A time is a point on the clock. start=, end=, at()'s cursor and min_time= take one:
| Form | Means |
|---|---|
"-30s", "-2m", "-1.5h", "-1d" |
that long before now |
"+30s" |
that long after now |
"now" |
now |
"2026-09-02T21:30:00Z" |
ISO 8601 |
datetime(...) |
a Python datetime; a naive one is read as UTC |
A duration is a length. duration=, last=, until= and resample()'s interval take one, unsigned:
| Form | Means |
|---|---|
"500ms", "30s", "2m", "1.5h", "1d" |
that much time |
timedelta(minutes=2) |
the same |
120 |
seconds, as a number |
Passing a signed string where a duration belongs is an error that says so, and so is passing a bare integer where a time belongs — 1757000000 is far more often seconds-by-mistake than an epoch in nanoseconds. Write "-30s".
Run a query¶
start is required on a live agent; end defaults to now. paths takes a string, a Signal, or a sequence of either. The result is a SignalFrame — Arrow columns plus the metadata that says what you actually got — and it renders itself: row count, time range, and one line per column with its unit, type and non-null count.
How a frame joins signals¶
A frame is one row per distinct timestamp. Every requested signal is a column, and a cell is null wherever that signal had no sample at that instant. Nothing is interpolated, resampled or forward-filled on the way in: what you get back is what was recorded, joined on time.
So a frame over two messages that tick at different rates is sparse, and the per-column non-null count in its repr is how you see that:
mixed = agent.query(
["bus0/BMS_message/status.pack_current", "bus1/inverter_status.battery_power"],
start="-1m",
)
mixed
# SignalFrame(rows=647, signals=2, 2026-09-04 17:05:42.063 → 17:06:41.737)
# bus0/BMS_message/status.pack_current A Float32 59 non-null
# bus1/inverter_status.battery_power W Float64 590 non-null
Alignment is explicit. ffill() carries each column's last value forward — sample-and-hold, the right reading for a signal that only reports on change — and dropna() drops the rows that still have nothing:
Write those two calls whenever you combine columns from different messages. integrate() and derivative() refuse a series with nulls rather than guessing across a gap, and their error names this pair as the fix.
Signals from the same message share a timestamp by construction, so a frame over one message is always dense.
Downsample and bound the result¶
downsample=N runs the agent's M4 strategy and returns about N points per signal, preserving the shape of the trace — the right tool for charting a long window:
max_rows=N caps the row count instead; 0 (the default) means no cap. The two are mutually exclusive. Either way frame.downsampled and frame.truncated tell you whether the agent had to reduce what it sent, and the frame's repr adds a flags: line naming whichever is set.
Read a frame¶
frame.keys() # column paths
frame["bus0/BMS_message/cells.cell_0"] # one column, as a SignalSeries
frame.items() # (path, series) pairs
len(frame) # rows
frame.head(5) # first rows, still a frame
frame.short_names() # drop the shared path prefix from column names
frame.rename(str.upper) # or rename with a mapping or a callable
frame.between(start, end) # narrow to a sub-window, in memory
frame.resample("10s", "mean") # re-bucket on a fixed interval
frame.describe() # count/mean/std/quartiles per column, with units
describe() is the fastest way to see a whole frame's shape at once, and it returns a pandas DataFrame.
Hand off to pandas / Arrow¶
You do not need these to see a frame — every object here renders itself. Convert a SignalFrame to a pandas DataFrame or a pyarrow Table when you want to do something the SDK does not do:
frame.to_pandas() # UTC DatetimeIndex, one column per signal
frame.to_arrow() # the underlying pyarrow.Table
to_pandas() indexes by a timezone-aware UTC DatetimeIndex. Pass index=False to get time as an ordinary column.
Chart a frame¶
plot() emits Vega-Lite. The app renders it inline, an HTML export renders it offline, and nothing has to be added to dependencies:
short_names() first is usually what you want — otherwise the legend carries the full path. A single series plots the same way:
Compute on series with units¶
Every column of a frame is a SignalSeries: one Arrow column that knows its path, its unit, and the frame's time axis. Two series from the same frame share that axis by identity, so they compose without an alignment step.
Units are an exponent map over the tokens in the catalog string, so they compose through arithmetic and cancel where they should:
pack = agent.query(
["bus0/DCDC_message/status.input_voltage", "bus0/BMS_message/status.pack_current"],
start="-1m",
).ffill().dropna()
voltage = pack["bus0/DCDC_message/status.input_voltage"] # V
current = pack["bus0/BMS_message/status.pack_current"] # A
power = voltage * current # W
energy = power.integrate() # W·s, rendered J
slew = current.derivative() # A/s
(power / current).unit # 'V' — the A cancels
A derived series is named after the operands it came from, so a chart legend and a check row say where the numbers came from:
power.name
# 'bus0/DCDC_message/status.input_voltage × bus0/BMS_message/status.pack_current'
energy
# SignalSeries 'integrate(bus0/DCDC_message/status.input_voltage × bus0/BMS_message/status.pack_current)' (J) len=59 nulls=0 2026-09-04 17:05:42.760 → 17:06:40.831
# [0, 1199, 1744, …, 68132]
* and / compose units; + and - require them to match and raise ValueError when they don't:
voltage + current
# ValueError: cannot add 'V' and 'A'; convert first (pandas + pint) or assert with .with_unit()
Scalars, Arrow arrays and plain sequences are unit-blind, so power * 0.001 keeps W. A pandas or numpy operand is refused rather than paired by position, because each library aligns by its own rules:
series * pandas_series
# TypeError: cannot combine a SignalSeries with a pandas Series; do the
# arithmetic in pandas after SignalSeries.to_pandas()
with_unit() asserts a unit you know to be right; rename() relabels a derived series.
Aggregations¶
An aggregation returns a NamedScalar — a float that also carries the name of what it measured and its unit, and prints to four significant digits:
series.min() # 3.629 V
series.max() series.mean() series.sum()
series.median() series.std() # ddof=1, the sample standard deviation
float(series.min()) is the exact value. count() is the one aggregation that is not a NamedScalar: it returns a plain int.
median is exact, not interpolated. Nulls are skipped by every aggregation.
Integrate, differentiate, mask¶
power.integrate() # cumulative trapezoid over time; first sample 0
current.derivative() # np.gradient over time
power.where(current > 25.0) # keep matching samples, null the rest
current.abs() current.diff() current.cumsum()
current.clip(0.0, 30.0)
series.ffill() series.dropna() series.isna()
Comparison operators on a series produce a boolean series, which is what where() masks with. An aggregation over a mask answers the narrower question: power.where(current > 25.0).mean() is the mean power under load.
Escape hatch and back¶
When the built-in surface runs out, take the data to pandas or numpy and come back:
series.to_pandas() # pandas Series, UTC DatetimeIndex
series.to_numpy()
series.to_arrow()
SignalSeries.from_pandas(smoothed, unit="V")
Read latest values¶
latest() answers "what is it right now", with no window:
agent.latest("bus0/BMS_message/status.pack_current")
# pack_current = 30.59 A @ 2026-09-04 17:06:40.831 (bus0/BMS_message/status, localhost)
A single literal path returns a LatestValue carrying the value, its unit, its enum label if it has one, and its timestamp. A pattern or a sequence returns a Snapshot, a read-only Mapping[str, LatestValue] — snapshot[path], in, .items(), len() all work, and it renders as a table:
agent.latest("bus0/BMS_message/cells.*")
# Snapshot @ 2026-09-04 17:06:40.831 · 8 signals
# bus0/BMS_message/cells.cell_0 3.653 V
# bus0/BMS_message/cells.cell_1 3.658 V
# bus0/BMS_message/cells.cell_2 3.661 V
# bus0/BMS_message/cells.cell_3 3.084 V
# bus0/BMS_message/cells.cell_4 3.664 V
# bus0/BMS_message/cells.cell_5 3.669 V
# bus0/BMS_message/cells.cell_6 3.676 V
# bus0/BMS_message/cells.cell_7 3.685 V
For one path: no catalog match raises SignalNotFound, and a match with no sample inside lookback raises NoData. For a pattern or a sequence, misses simply omit their key.
Read at a cursor time¶
at() is the freeze-frame: the value of every requested signal at or before one instant.
It returns a Snapshot, keyed by path. min_time= floors the search so a stale, unrelated sample cannot pose as context — anchor it to the incident rather than to the clock:
Replay a window of changes¶
window() is built for states and events, where a query would return thousands of rows repeating one string. It returns a ReplayWindow: the opening snapshot at start, plus changes, one entry per update after it.
replay = agent.window("bus1/inverter_status.mode", start="-1m", duration="1m")
replay
for change in replay.changes:
print(f"{change.time:%H:%M:%S} {change.value}")
start takes the time grammar, duration the duration grammar. display_fps= thins the change stream for a live display; the default, 0, thins nothing.
Watch live values¶
watch() polls and yields a Snapshot per tick. until= bounds it with the duration grammar, which is what makes a watching notebook finish on its own:
for tick in agent.watch(["bus1/inverter_status.mode"], interval=1.0, until="30s"):
value = tick["bus1/inverter_status.mode"]
print(f"{value.time:%H:%M:%S} {value.value}")
Without until the loop runs until you interrupt it. on_error="skip" tolerates transient failures rather than raising, up to max_consecutive_errors.
Polling can miss a transition between ticks; window() cannot. Use watch() for a dashboard and window() for evidence.
Errors¶
Every failure is a typed subclass of AgentError, so you can catch the class or the family:
| Error | When |
|---|---|
AgentUnavailable |
nothing answered before the timeout |
SignalNotFound |
a path or pattern matched nothing |
AmbiguousSignal |
a path resolves to more than one signal — two producers, traces or segments own it, or it matches several columns of a frame |
NoData |
the signal resolved but had no sample in range |
QueryRangeTooLarge |
the window asks for more than the agent will send |
ConnectionTargetError |
the target string isn't a usable endpoint |
from zelos_sdk import errors
try:
agent.latest("bus0/BMS_message/status.pack_current")
except errors.NoData:
...
What's next¶
-
The same calls against a saved
.trz— one file, or several runs on a shared clock. -
Turn a queried series into a rule with a result and the evidence behind it.
-
Put all of this in a markdown file the agent runs.
-
Call into a producer instead of reading from it.
-
Every class, method and parameter, generated from the package.