Elektro is the Python client for the Elektro electrophysiology backend — a service for storing, organizing and analysing ephys data (traces, recordings, stimuli, experiments) and the biophysical neuron models behind them.
It splits every object into two halves and keeps them in sync for you: the metadata
(a trace's name, its dataset, the experiment it belongs to) lives in a GraphQL API, while the
heavy numeric payload (the actual signal, arrays, tables, meshes) is streamed to and from
object storage as Zarr and Parquet. You
hand Elektro a NumPy array; it uploads the bytes, registers the metadata, and gives you back a
typed object whose .data is a lazy xarray.DataArray — so
you can ask for trace.data.max() on a multi-gigabyte recording and only the bytes you touch
are ever pulled down.
It answers questions like "store this recording and give it back to me as an array I can slice without downloading the whole thing" — and does it as one line of typed, async-first Python.
Note: Elektro is built for the Arkitekt ecosystem. Configuration and authentication come from Fakts, the transport from Rath, and its domain types plug straight into Rekuest workflows.
pip install elektroElektro requires Python ≥ 3.11. Two optional extras pull in heavier, domain-specific dependencies only when you need them:
pip install "elektro[table]" # pyarrow + pandas + duckdb — query Parquet stores
pip install "elektro[neuron]" # the NEURON simulator — build & run biophysical modelsElektro is responsible for the client side of ephys data and nothing else:
- Modelling — it exposes the Elektro domain (traces, datasets, experiments, simulations, neuron models, …) as typed, validated Python objects generated from the GraphQL schema.
- Moving data — array-, table- and file-like values you pass into a mutation are transparently uploaded to object storage before the GraphQL call, and read back lazily afterwards. You work with NumPy/xarray; Elektro handles Zarr, Parquet and credentials.
- Composing — it bundles a GraphQL client (
rath) and an object-storage client (datalayer) into oneElektroapp, usable sync or async.
It does not run the backend, define the schema, or mint your credentials — those come from the Elektro server and from Fakts / Arkitekt. Elektro is the piece that turns a configured, authenticated connection into typed objects and lazy arrays.
The domain types you work with (all registered as Rekuest structures in elektro/rekuest.py):
| Type | Meaning |
|---|---|
Trace |
A time-series recording — the signal itself, backed by a Zarr store. |
Dataset |
A named collection that groups traces and other objects. |
Experiment |
An experimental setup tying together recordings and stimuli. |
Recording |
A single recording of a trace within an experiment. |
Stimulus |
A stimulus protocol applied during an experiment. |
Block |
An organisational unit grouping recordings/stimuli in time. |
Simulation |
A run of a biophysical neuron model. |
NeuronModel |
A specific morphology + biophysics model. |
ModelCollection |
A library of related neuron models. |
ROI |
A region of interest on a trace. |
Heavy payloads enter the API as store-backed scalars (elektro/scalars.py). You pass a
plain in-memory value; Elektro uploads it and substitutes a store reference:
| Scalar | You pass… | Stored as… |
|---|---|---|
TraceLike |
np.ndarray / xr.DataArray |
a Zarr store |
ArrayLike |
an N-dimensional array | a Zarr store |
ParquetLike |
a pandas / Arrow table | a Parquet store |
FileLike |
a file path / handle | a big-file store |
MeshLike |
a 3D mesh | a mesh store |
An Elektro app is a small composition of two clients (elektro/elektro.py):
┌──────────────────── Elektro ────────────────────┐
│ │
│ rath: ElektroRath datalayer: DataLayer│
│ (GraphQL metadata) (object storage) │
└───────┬──────────────────────────────┬──────────┘
│ │
write path │ │
from_trace_like(np.ndarray) │
│ │
┌───────▼────────┐ upload bytes ┌───▼──────────────┐
│ UploadMiddleware├─────────────────►│ Zarr / Parquet │
│ (intercepts the │ │ object storage │
│ array variable)│◄─────────────────┤ (via obstore) │
└───────┬────────┘ store id └───▲──────────────┘
│ │
┌───────▼────────┐ │ lazy reads
│ GraphQL mutation│ carries store id │
│ via ElektroRath │ │
└───────┬────────┘ │
│ │
read path │ Trace │
trace.data ──── HasZarrStoreTrait ───────►│ xr.DataArray
(only the bytes you touch are pulled)
The write path: you call from_trace_like(array, …); UploadMiddleware
(elektro/middleware/upload.py) pulls the array out of the variables, uploads it to a Zarr
store through the datalayer, and the GraphQL mutation only ever carries the resulting store
id. The read path: the returned Trace mixes in HasZarrStoreTrait, whose .data property
opens that Zarr store as a lazy xarray.DataArray — nothing is downloaded until you compute.
In the Arkitekt ecosystem the app is built and entered for you, and the top-level schema
functions in elektro.api.schema operate against the current connection:
import numpy as np
from elektro.api.schema import create_dataset, from_trace_like, get_random_trace
from arkitekt import easy
# Create a dataset (metadata only)
with easy():
dataset = create_dataset(name="my_experiment")
# Upload a 1-D signal — the array is stored in Zarr, the metadata in GraphQL
trace = from_trace_like(
np.random.random((1000,)),
name="signal_1",
dataset=dataset.id,
)
trace.id # the new trace's id
trace.data.shape # (1000,) — a lazy xarray.DataArray, materialized on access
# Fetch one back
again = get_random_trace()Every function has an a-prefixed async twin (acreate_dataset, afrom_trace_like,
aget_random_trace, …) for use inside an async context:
dataset = await acreate_dataset(name="my_experiment")
trace = await afrom_trace_like(np.random.random((1000,)), name="signal_1", dataset=dataset.id)Outside Arkitekt you can construct the app yourself —
Elektro(rath=ElektroRath(...), datalayer=DataLayer(...))— and use it as a context manager (with elektro: ...). Seetests/conftest.pyfor a full manual wiring against a local deployment.
Lazy arrays. A Trace (and anything mixing in HasZarrStoreTrait) exposes its payload as
an xarray.DataArray that is only fetched on demand:
data = trace.data # xr.DataArray (lazy, dask-backed)
peak = data.max().compute() # pulls only what it needs
trace.multi_scale_data # list[xr.DataArray] — the multiscale pyramid
trace.export_csv("signal.csv") # dump the trace to CSVTables. Parquet-backed stores expose a queryable relation (requires elektro[table]):
relation = store.duckdb_relation # a duckdb.DuckDBPyRelation you can SQL against
df = store.parquet_dataset # the underlying Parquet datasetFiles & meshes. Big-file and media stores download to disk on request:
path = bigfile_store.download() # → local file path
path = media_store.download("frame.png") # presigned downloadUnits. Physical quantities are real pint quantities via
kanne (Milliseconds, Micrometers, Microliters,
…), coerced on the wire by CoercePintLink so the server always receives canonical units.
Elektro models biophysical neurons as a typed hierarchy —
ModelConfig → Cell → Topology (sections & connections) → Biophysics (mechanisms &
conductances) → Compartment. The input traits (elektro/traits.py) make these easy to build
and inspect:
config.as_input() # ModelConfig → its GraphQL input form
biophysics.as_dataframe() # inspect compartments/mechanisms as a pandas DataFrameWith the elektro[neuron] extra installed, elektro/neuron/ parses NEURON .mod mechanism
files and runs simulations directly from a ModelConfig, recording the result as a
Simulation.
ElektroService(elektro/arkitekt.py) — registers Elektro as an Arkitekt service. It builds a fully wiredElektroapp from a Fakts config (FaktsAuthLinkfor auth,FaktsAIOHttpLink/FaktsGraphQLWSLinkfor transport, aFaktsDataLayerfor storage) so an Arkitekt app gets a ready-to-use client with no manual setup.structure_reg(elektro/rekuest.py) — registers the domain types (Trace,Dataset,Experiment,Simulation, …) as Rekuest structures, each under an identifier like@elektro/trace, so they can be passed in and out of Rekuest workflow nodes (expand/shrink/search handled for you).
Under the hood the transport is Rath (ElektroRath, a link
chain of file extraction, dicting, pint coercion, auth and an HTTP/WebSocket split), the
composition is koil, and object storage goes through
obstore + Zarr/Parquet.
from elektro import Elektro, ElektroService, structure_reg
# the building blocks of the app
from elektro.rath import ElektroRath
from elektro.datalayer import DataLayer
# the domain operations (turms-generated; sync + async `a*` twins)
from elektro.api.schema import (
create_dataset,
from_trace_like,
get_random_trace,
create_experiment,
create_simulation,
Trace,
Dataset,
)elektro.api.schema is generated from the GraphQL schema by
turms and regenerated whenever the schema changes — it is
the source of truth for the full set of available operations and types.
uv sync
uv run pytestIntegration tests spin up a real Elektro + MinIO deployment with testcontainers and are marked
@pytest.mark.integration; tests needing the NEURON simulator are marked @pytest.mark.neuron.
Run just the fast suite with uv run pytest -m "not integration and not neuron".
See RELEASING.md for the semantic-release flow (main → stable,
next → rc prereleases, N.x → maintenance).