SDK Usage
The energydb SDK is built around one client that owns a PostgreSQL connection
pool and constructs a timedb.TimeDBClient for ClickHouse I/O. It ships
in two flavors:
Client— the synchronous facade. Every method blocks; the connection pool is opened eagerly on construction. This is what the examples below use.AsyncClient— theasync/awaitclient the sync facade wraps.await client.open()once before use (or use it as an async context manager). EveryClientmethod shown here has an identicalAsyncClientcoroutine.
Around either client sit two fluent scopes (NodeScope and
EdgeScope) that let you navigate the hierarchy and operate
on a single node or edge in idiomatic Python.
Overview
energydb stores three kinds of objects, all in one PostgreSQL schema — named by
the ENERGYDB_SCHEMA environment variable, defaulting to public:
Nodes — Portfolio, Site, WindTurbine, Battery, JunctionPoint, … Identified by a UUID7 generated when the
Elementis constructed in Python; that same UUID is the row primary key in Postgres.Edges — typed cross-tree links (Line, Link, Pipe, Interconnection) between two nodes, also UUID-keyed.
Series — time series owned by exactly one node or edge. The catalog row lives in PostgreSQL; the values themselves live in TimeDB’s ClickHouse
series_valuestable.
Time series in energydb fall into two categories — a property of each series:
FLAT— actuals / measurements, one value pervalid_timeOVERLAPPING— versioned forecasts, multipleknowledge_timepervalid_time
The same client.write / client.read pipeline handles both. OVERLAPPING
series additionally require a knowledge_time (kwarg or column) on every
write.
Getting Started
Import the package and instantiate the client:
import energydb as edb
client = edb.Client() # reads TIMEDB_PG_DSN / TIMEDB_CH_URL from env
The constructor accepts explicit pg_conninfo= and ch_url= kwargs for
custom connections; environment variables are the default. Both are
keyword-only. The synchronous Client opens its pool on construction and
should be closed with client.close() (or used as a with block); the
AsyncClient requires await client.open() before its
first call.
energydb re-exports the EnergyDataModel public API under edb.* (see
edb.wind, edb.solar, edb.battery, edb.grid, edb.Site,
edb.Portfolio, …) and the TimeDB types
(TimeSeries, DataType,
TimeSeriesType).
Database Connection
The client reads its connection settings from environment variables by default:
TIMEDB_PG_DSN(orDATABASE_URL) — PostgreSQL DSN (must be a URI)TIMEDB_CH_URL— ClickHouse HTTP URLENERGYDB_SCHEMA— PostgreSQL schema for energydb’s tables (defaultpublic)
You can also use a .env file in your project root (see
Installation). The ClickHouse client honors TimeDB’s own
TIMEDB_CH_TIMEOUT / TIMEDB_CH_CONNECT_TIMEOUT tunables.
For programmatic use, instantiate the client with explicit settings:
client = edb.Client(
pg_conninfo="postgresql://user:pw@localhost:5432/energydb",
ch_url="http://default:devpassword@localhost:8123/default",
)
Schema Management
Creating the Schema
Before using the client, create the database schema:
client.create()
This runs Base.metadata.create_all against PostgreSQL — creating the
node, edge, series, and runs tables plus the series_meta
view — then delegates to TimeDB to create the ClickHouse series_values and
run_series tables. When ENERGYDB_SCHEMA names a non-default schema, it
also issues CREATE SCHEMA IF NOT EXISTS first; the default public
schema is used as-is. Finally it best-effort provisions the ClickHouse
PostgreSQL() meta-engine table used by the concurrent read path — a failure
there is logged, not raised (reads fall back to the sequential path). Safe to
run repeatedly.
Use setup_ch_meta_engine() as the explicit, raising
alternative that (re)creates the series_meta view and the engine table and
clears the session’s engine-degraded flag — call it to re-enable concurrent
reads after fixing engine infrastructure.
Deleting the Schema
To drop energydb’s tables and all ClickHouse values (use with caution):
client.delete()
WARNING: this is destructive. With a named ENERGYDB_SCHEMA it runs
DROP SCHEMA … CASCADE, removing every node, edge, series declaration, and
run. With the default public schema it drops only energydb’s own four
tables (series, runs, edge, node) — never the shared public
schema, which would take the host application’s tables with it. Either way it
then drops every value in ClickHouse.
Hierarchies and Topology
Everything below covers writing, reading, and editing the portfolio structure itself — nodes, edges, and series declarations. Time-series I/O for the values that flow through that structure is covered in the next section.
Building with register_tree
The single entry point for creating structure — every node, edge, and
series declaration — is register_tree(). It is
declarative and atomic: build the entire portfolio top-down as one nested
expression in Python, then persist it in one call.
import energydb as edb
t01 = edb.wind.WindTurbine(
name="T01", lat=55.01, lon=3.02, capacity=3.5, hub_height=80,
timeseries=[
edb.TimeSeries(name="power", unit="MW",
data_type=edb.DataType.ACTUAL),
edb.TimeSeries(
name="power", unit="MW",
data_type=edb.DataType.FORECAST,
timeseries_type=edb.TimeSeriesType.OVERLAPPING,
),
],
)
t02 = edb.wind.WindTurbine(name="T02", capacity=3.5, hub_height=80, timeseries=[
edb.TimeSeries(name="power", unit="MW",
data_type=edb.DataType.ACTUAL),
])
portfolio = edb.Portfolio(
name="my-portfolio",
members=[edb.Site(name="Offshore-1", lat=55.0, lon=3.0, members=[t01, t02])],
)
root_uuid = client.register_tree(portfolio)
Every Element got its id (UUID7) at construction; register_tree
writes those UUIDs straight into the row primary keys in PostgreSQL.
Note
register_tree is create-only and structure-only.
Any UUID in the payload that already exists in the DB raises
ValueError— modify existing rows through scope mutators (NodeScope.rename(),.update,.delete,.move_to,.add) instead, optionally batched in aClient.transaction().Names must be non-empty and must not contain
/(the path separator). Violations raiseValueErrorbefore any SQL runs and are also rejected by PostgreSQLCHECKconstraints.If any node/edge in the tree carries non-empty inline
TimeSeries.dfdata, the call raisesValueError— write data separately viawrite()or the scope helpers (see below).
Grafting onto an existing tree
Pass under= to attach the new tree’s root under an existing parent.
The parent path (or uuid) must resolve to an existing node:
# Add a new site under an existing portfolio.
client.register_tree(
edb.Site(name="Offshore-2", lat=55.5, lon=3.5, members=[t03]),
under=("my-portfolio",),
)
Dry run
Pass dry_run=True to preview the diff before applying. The call
returns a TreeDiff and rolls back — no DB state
changes.
diff = client.register_tree(portfolio, dry_run=True)
diff.render()
# Looks good — apply.
root_uuid = client.register_tree(portfolio)
The diff carries flat node_changes / edge_changes lists and
binned views (node_inserts, edge_inserts).
TreeDiff.render() renders a tree-shaped textual preview:
Portfolio 'my-portfolio'
├── + Site 'Offshore-1' [insert]
│ ├── + WindTurbine 'T01' [insert]
│ └── + WindTurbine 'T02' [insert]
edges:
+ Line 'Cable-1' <a-uuid> → <b-uuid> [insert]
Reconstructing trees
Pull a node or a whole subtree back as a regular EnergyDataModel object — same UUIDs, ready for inspection or in-memory edits.
# Single node, eager
turbine = client.get_node("my-portfolio", "Offshore-1", "T01").get()
turbine = client.get_node(uuid=t01.id).get()
# Full subtree as an EDM tree
tree = client.get_tree("my-portfolio")
tree_with_series = client.get_tree("my-portfolio", include_series=True)
With include_series=True, every reconstructed node carries its registered
series as metadata-only TimeSeries entries (df=None)
on timeseries.
When you want the raw row rather than a reconstructed EDM object — any
node_type / edge_type string, no class lookup — use get_raw() and
the lazy navigation helpers on a scope:
scope = client.get_node("my-portfolio", "Offshore-1", "T01")
scope.get_raw() # {uuid, node_type, name, data, parent_uuid, path} — None if uuid-addressed & missing
scope.children() # direct children, one level: list of {uuid, node_type, name, data, parent_uuid}
scope.descendants() # whole subtree below (excludes self), same dict shape
scope.path() # resolved path tuple
# children / descendants accept a type= filter
turbines = client.get_node("my-portfolio").descendants(type="WindTurbine")
EdgeScope.get_raw() returns {uuid, edge_type, name, data,
from_node_uuid, to_node_uuid} and works for any edge_type string;
EdgeScope.from_node() / .to_node() return the endpoint
NodeScopes.
Flat queries by type / subtree / properties (return lists of EDM objects):
turbines = client.query_nodes(type="WindTurbine", within="my-portfolio")
lines = client.query_edges(type="Line", within="my-portfolio")
Editing single elements
With UUID identity, mutations resolve in one statement at the database layer:
Rename (same uuid, different
name) → silentUPDATEMove (same uuid, different
parent_uuid) → silentUPDATEProperty edit (same uuid, different
data) → silentUPDATEType change (same uuid, different
node_type) → rejected; element type is immutable for a given id
Address the node by path or uuid and use the fluent scope ops:
t01 = client.get_node("my-portfolio", "Offshore-1", "T01")
t01.rename("T01-A")
t01.update(data={"capacity": 4.5})
t01.move_to(client.get_node("my-portfolio", "Onshore-1"))
t01.delete()
update defaults to a shallow JSONB merge — the passed keys are merged
over the existing data. Pass replace_data=True to overwrite data
wholesale instead. move_to rejects re-parenting into self or any descendant
(cycle detection). rename and update are idempotent and round-trip
safe. Every mutator also accepts dry_run=True, returning a
TreeDiff without touching the database.
The same surface exists on edges (edge move_to takes keyword-only
from_node= / to_node= endpoints):
e = client.get_edge(uuid=line.id)
e.update(data={"capacity": 600}) # shallow merge; replace_data=True to overwrite
e.move_to(from_node=bus_a_scope, to_node=bus_c_scope)
e.delete()
Declaring series
The recommended way to register series is to declare them as metadata-only
TimeSeries entries on each Element and let
register_tree persist them with the rest of the structure.
For surgical additions on an existing node or edge, scopes expose
register_series:
client.get_node("my-portfolio", "Offshore-1", "T01").register_series(
name="wind_speed",
canonical_unit="m/s",
data_type="actual",
timeseries_type="FLAT",
retention="forever", # optional; derived from timeseries_type when omitted
description="Nacelle anemometer", # optional
)
register_series returns the integer series_id. Pass a metadata-only
TimeSeries directly instead of the individual fields:
ts = edb.TimeSeries(
name="wind_speed", unit="m/s", data_type=edb.DataType.ACTUAL,
)
client.get_node(uuid=t01.id).register_series(ts)
retention, canonical_unit, and the owner columns are immutable after
insert (enforced by a Postgres trigger). Reclassifying a series means
registering a new one — this preserves ClickHouse-side data integrity. When
retention is omitted it is derived from timeseries_type: FLAT
(actuals) → forever, OVERLAPPING (forecasts) → medium.
``data_type`` and ``timeseries_type``. data_type is a value from the
DataType enum, passed as a case-insensitive string
(lowercased internally). The full vocabulary is actual, observation,
derived, calculated, estimation, forecast, prediction,
scenario, simulation, reconstruction, reference, baseline,
benchmark, ideal — a hierarchy (e.g. observation rolls up to
actual). timeseries_type is the temporal shape and must be one of the
two TimeSeriesType values: FLAT (one value per
valid_time) or OVERLAPPING (versioned forecasts, many
knowledge_time per valid_time). timeseries_type is the one series
attribute that can be changed after registration.
Edges and Grid Topology
Edges model typed cross-tree links — lines, transformers, pipes, interconnections. They have full CRUD, can carry their own time series, and support endpoint navigation.
bus_a = edb.grid.JunctionPoint(name="BusA")
bus_b = edb.grid.JunctionPoint(name="BusB")
line = edb.grid.Line(
name="Cable-1", capacity=500,
from_element=bus_a, to_element=bus_b,
)
# Persist topology in one call — register_tree handles nodes then edges.
client.register_tree(edb.Portfolio(name="Grid", members=[bus_a, bus_b, line]))
For standalone edges between nodes that already exist in the database, use
create_edge() directly:
client.create_edge(line)
Look an edge up by uuid or by the (from_path, to_path, type) triple:
e = client.get_edge(uuid=line.id).get()
e = client.get_edge(("Grid", "BusA"), ("Grid", "BusB"), type="Line").get()
Series on an edge:
scope = client.get_edge(uuid=line.id)
scope.register_series(
name="power_flow", canonical_unit="MW",
data_type="actual", timeseries_type="FLAT",
)
scope.write(df, name="power_flow", data_type="actual")
scope.read(name="power_flow", data_type="actual")
Targeted Time-Series I/O
Use NodeScope.write /
EdgeScope.write when you have a single
known series — patching a bad segment, backfilling a gap, exploring data
interactively, or driving a small ETL. The series is resolved exactly once
via the scope’s path or uuid before any data reaches the database.
Targeted I/O (scope |
Bulk I/O ( |
|
|---|---|---|
Targets |
One series at a time |
Many series across many nodes / edges |
Typical use |
Patching, backfilling, exploration |
ETL pipelines, scheduled loads, cross-portfolio reads |
Routing |
Implicit (the scope’s resolved uuid) |
Manifest column: |
Writing
Build a small DataFrame with valid_time and value (and optionally
knowledge_time for OVERLAPPING series), then write it through the
scope:
from datetime import UTC, datetime
import pandas as pd
start = datetime(2026, 1, 1, tzinfo=UTC)
df = pd.DataFrame({
"valid_time": pd.date_range(start, periods=24, freq="1h", tz="UTC"),
"value": [2.5 + 0.1 * h for h in range(24)],
})
client.get_node("my-portfolio", "Offshore-1", "T01").write(
df, data_type="actual", name="power",
)
A pandas or polars DataFrame is accepted; everything is converted to polars
internally. write() returns a WriteResult — an int
whose value is the run_id used for this batch (so existing code that
treats the result as a run_id keeps working), carrying .written /
.skipped row counts as attributes. All writes are recorded in the
energydb.runs table, keyed by a client-side UUID7-derived integer.
Optional kwargs:
unit— declare the incoming unit; if it differs from the series’ registeredcanonical_unit, pint computes the scalar factor and rescales every value before writingknowledge_time— broadcast a singleknowledge_time(required for OVERLAPPING series unless aknowledge_timecolumn is on the DataFrame)run_id,workflow_id,model_name,run_start_time,run_finish_time,run_params— provenance metadata stored inenergydb.runsskip_unchanged/unchanged_scope— drop rows that only duplicate the latest stored(value, annotation, changed_by)before the insert;unchanged_scope(default"valid_time") selects the comparison key. Forwarded to the underlyingtimedbwrite. Theenergydb.runsrow is upserted regardless, so an all-skipped write still records a run.
Reading
A scope’s .read() reads every series that matches under the resolved
subtree. Pass data_type= and name= to narrow:
# Single-series read
df = client.get_node("my-portfolio", "Offshore-1", "T01").read(
data_type="actual", name="power", start_valid=start,
)
# Subtree read — every actual 'power' across the whole portfolio
df = client.get_node("my-portfolio").read(data_type="actual", name="power")
# Filter descendants by EDM type
df = client.get_node("my-portfolio").where(type="WindTurbine").read(
data_type="actual", name="power",
)
The read returns a polars DataFrame by default; pass backend="pandas" for
pandas. Default columns are path (Utf8, joined with /),
data_type, name, valid_time, value. knowledge_time and
change_time appear when the corresponding include_* kwargs are
set. Internal identifiers (series_id, node_uuid, edge_uuid)
are never exposed on the result.
For edge reads, the hierarchy columns are from_path, to_path (both
Utf8, joined with /) and edge_type.
Note
Scope auto-strip. When a scope .read() resolves to a single
series (e.g. fully qualified path + data_type= + name=) and
output="frame", the path/data_type/name columns are stripped —
you only get the data columns (valid_time, value, plus opt-in
knowledge_time / change_time). The caller already knows the
identity through the scope expression; re-broadcasting it on every row
is pure noise.
Time-range filters mirror TimeDB:
df = scope.read(
data_type="actual", name="power",
start_valid=datetime(2026, 1, 1, tzinfo=UTC),
end_valid=datetime(2026, 2, 1, tzinfo=UTC),
start_known=datetime(2026, 1, 1, tzinfo=UTC), # OVERLAPPING only
end_known=datetime(2026, 1, 15, tzinfo=UTC),
include_updates=False, # correction chain off
include_knowledge_time=False, # collapse to latest
)
Per-window cutoffs (for backtesting / day-ahead simulation) are exposed via
NodeScope.read_relative — same
window-length / issue-offset / daily-shorthand semantics as
timedb.TimeDBClient.read_relative().
Bulk Manifest I/O
For production pipelines that touch many series across many nodes or edges
in one call, use Client.write and
Client.read with a manifest DataFrame. The
same engine drives the scope helpers, so guarantees are identical.
The manifest carries one routing column plus data_type, name, and
the data columns. The routing column is autodetected from the column names
— exactly one of:
node_uuid— programmatic routing by UUIDedge_uuid— programmatic routing for edge-attached seriespath— human-readable,Utf8joined with/(e.g."my-portfolio/Offshore-1/T01")./is reserved as the separator; names containing/are rejected at registration. The manifest must useUtf8—List(Utf8)from earlier API versions is rejected with an explicit migration message.from_path+to_path+edge_type— human-readable routing for edge-attached series (all three columns required together), the edge analogue ofpath. Each isUtf8joined with/; the edge is resolved server-side via its endpoint nodes’ paths and type. Symmetric with the edge read output columns, so an edge read’s frame can be fed back in as a manifest without resolvingedge_uuidfirst.
write() — long-format multi-series ingestion
import polars as pl
from datetime import UTC, datetime, timedelta
base = datetime(2026, 1, 1, tzinfo=UTC)
hours = [base + timedelta(hours=h) for h in range(24)]
manifest = pl.DataFrame({
"path": ["my-portfolio/Offshore-1/T01"] * 24,
"data_type": ["actual"] * 24,
"name": ["power"] * 24,
"valid_time": hours,
"value": [2.5 + 0.1 * h for h in range(24)],
})
client.write(manifest)
The other two routing forms are equivalent:
# By node uuid (programmatic)
pl.DataFrame({
"node_uuid": [str(t01.id)] * 24,
"data_type": ["actual"] * 24,
"name": ["power"] * 24,
"valid_time": hours,
"value": [2.5 + 0.1 * h for h in range(24)],
})
# By edge uuid (for edge-attached series)
pl.DataFrame({
"edge_uuid": [str(line.id)] * 24,
"data_type": ["actual"] * 24,
"name": ["power_flow"] * 24,
"valid_time": hours,
"value": [200.0 + h for h in range(24)],
})
# By edge triple (human-readable; all three columns required)
pl.DataFrame({
"from_path": ["my-grid/BusA"] * 24,
"to_path": ["my-grid/BusB"] * 24,
"edge_type": ["Line"] * 24,
"data_type": ["actual"] * 24,
"name": ["power_flow"] * 24,
"valid_time": hours,
"value": [200.0 + h for h in range(24)],
})
Routing modes are mutually exclusive — passing more than one routing column
raises ValueError. Supplying only part of the edge triple (e.g.
from_path + to_path without edge_type) is likewise a ValueError.
Series must already be registered (typically via
register_tree()) — unresolved
(owner, data_type, name) triples raise before any data reaches
ClickHouse.
Optional columns and kwargs:
unit(column or kwarg) — incoming unit, auto-converted to each series’ canonical unit; mutually exclusive when both forms are suppliedknowledge_time(column or kwarg) — required for OVERLAPPING seriesrun_id,workflow_id,model_name,run_start_time,run_finish_time,run_params— provenance metadata; defaultrun_idis one client-generated UUID7-derived integer per callskip_unchanged/unchanged_scope(kwargs) — drop rows that only duplicate the latest stored value before the insert;unchanged_scope(default"valid_time") selects the comparison key. Forwarded to the underlyingtimedbwrite.
Returns a WriteResult — an int run_id carrying
.written / .skipped row counts. The energydb.runs row is upserted
even when every row is skipped, so an all-skipped write still records a run.
read() — manifest-based multi-series read
The read manifest is the same shape, minus the data columns:
manifest = pl.DataFrame([
{"path": "my-portfolio/Offshore-1/T01", "data_type": "actual", "name": "power"},
{"path": "my-portfolio/Offshore-1/T02", "data_type": "actual", "name": "power"},
])
df = client.read(
manifest,
start_valid=datetime(2026, 1, 1, tzinfo=UTC),
end_valid=datetime(2026, 2, 1, tzinfo=UTC),
)
Returns a polars DataFrame by default; pass backend="pandas" for pandas.
Optional kwargs:
unit— request a specific unit; per-series scalar factor appliedstart_valid/end_valid— valid_time range (UTC)start_known/end_known— knowledge_time range (OVERLAPPING only)include_updates— expose correction chaininclude_knowledge_time— return one row per (knowledge_time, valid_time)
The result columns mirror the scope read: path, data_type,
name, valid_time, value for node manifests; from_path,
to_path, edge_type, data_type, name, valid_time,
value for edge manifests. path / from_path / to_path are
Utf8 joined with /. Internal identifiers (series_id,
node_uuid, edge_uuid) are never on the result.
Per-window relative reads use Client.read_relative, with the same parameters as TimeDB’s
read_relative().
Output modes
Both Client.read and the scope reads accept
an output= kwarg that controls return shape:
output="frame"(default) — one DataFrame with the identity columns broadcast on every row. Good for ETL and ad-hoc analysis where you want togroup_by(path)or filter further downstream.output="by_path"— adictkeyed by aSeriesKey(path, data_type, name)for node reads, or anEdgeSeriesKey(from_path, to_path, edge_type, data_type, name)for edge reads. Both areNamedTuples, so keys support positional and attribute access (key.path,key.name, …). Each value is one DataFrame per series, carrying only the data columns (valid_time,value, plus opt-inknowledge_time/change_time), sorted byvalid_timeascending.
Use by_path when downstream code naturally operates per-series — model
training, plotting, per-asset writes back. Series that resolve but have
no rows in ClickHouse still appear as keys with an empty sub-frame, so
callers can index by key without KeyError. find()
filters the result dict by partial key match (e.g. find(result,
name="power")).
by_series = client.read(manifest, output="by_path")
for key, sub in by_series.items():
train_one_model(key.path, sub) # key is a SeriesKey NamedTuple
The backend= kwarg is orthogonal: backend="polars" (default)
returns polars frames in both modes; backend="pandas" converts every
frame at the boundary.
Atomic batches with transaction()
For a sequence of mutations that must apply (or roll back) as a unit, open
a Client.transaction(). Mutations executed through the txn’s scope
factories share one borrowed pool connection and stay uncommitted until
Transaction.commit() is called explicitly. Exiting the
with-block without committing raises and rolls back.
with client.transaction() as txn:
txn.get_node("my-portfolio", "Offshore-1", "T01").update({"hub_height": 95})
txn.get_node("my-portfolio", "Offshore-1", "T02").rename("T02b")
txn.get_node("my-portfolio", "Rooftop-1", "B01").move_to(
("my-portfolio", "Offshore-1")
)
txn.preview().render() # aggregate diff of everything queued so far
txn.commit()
The transaction supports every scope mutator (rename, update,
move_to, delete, add, register_series) plus
Transaction.register_tree() for create-only inserts. Mid-transaction
reads on the same connection see the transaction’s own uncommitted writes.
Warning
Time-series I/O does not participate in the PG transaction.
scope.write(df, ...), scope.read(...), and
scope.read_relative(...) on a txn-bound scope raise
RuntimeError — the ClickHouse writes and the energydb.runs
inserts go through their own connection and would not roll back with
the PG transaction. Call Client.write() / Client.read()
directly outside the with-block when you need to mix structure
mutations and time-series I/O.
Resolve cache
Every Client keeps an in-process cache (the series registry)
of resolved series, node, and edge metadata. The cache is read-through
on the resolve hot path and write-through on every local mutation —
register_tree, register_series, rename, move_to,
delete, etc. all keep it consistent transparently. There is nothing
to invalidate by hand under normal use.
The one case the cache cannot observe is another process mutating
schema state this client has already cached — registering a new series,
deleting a node, or flipping a series’s timeseries_type. Reads from
the cached client will continue to serve stale metadata until you call:
client.invalidate_series_cache() # clear everything
client.invalidate_series_cache(owner_uuid=...) # evict one owner only
A focused eviction by owner_uuid is cheaper than a full clear when
you know exactly which node/edge changed.
Use Client.resolve_cache_stats() to see hit/miss counters and the
current cache size if you are tuning read latency.
Concurrent reads
When the ClickHouse meta-engine table is provisioned (see Schema
Management) and expressible for the query, reads run the PostgreSQL series
resolve in parallel with the ClickHouse value read — ClickHouse
self-resolves series_id through the PostgreSQL() engine table over the
series_meta view. The result is trimmed to the exact resolve and is
byte-identical to the sequential path. Queries that cannot express an engine
predicate (.where() property filters, uuid-addressed subtrees) fall back
to sequential automatically.
Two environment variables gate this path:
ENERGYDB_DISABLE_ENGINE=1— session kill-switch; always use the sequential path.ENERGYDB_ENGINE_STRICT=1— raise on an engine-read failure instead of degrading to sequential (useful in tests). Without it, the first failure degrades the session and later reads go sequential untilsetup_ch_meta_engine()is called again.
Run History
Every write creates one run row in energydb.runs. To list runs that wrote
data for a given series:
runs = client.read_runs_for_series(series_id=42)
# [
# {"run_id": 123..., "workflow_id": "nightly-forecast",
# "model_name": "ECMWF",
# "run_start_time": ..., "run_finish_time": ...,
# "run_params": {"horizon": 48}, "inserted_at": ...},
# ...
# ]
Run ids are client-side BIGINTs (top 63 bits of a UUID7), time-sortable, and
fit cleanly in Int64.
Error Handling
Common errors and how to handle them:
from energydb import IncompatibleUnitError
try:
client.register_tree(portfolio)
except ValueError as e:
if "contains '/'" in str(e) or "must be non-empty" in str(e):
# Illegal node/edge/series name.
...
else:
raise
try:
client.write(manifest)
except ValueError as e:
if "Series not registered" in str(e):
# Register series via register_tree first.
...
elif "knowledge_time is required for OVERLAPPING" in str(e):
...
else:
raise
except IncompatibleUnitError as e:
print(f"Unit mismatch: {e}")
Key exceptions:
ValueError— empty or/-containing names, missing routing columns, unresolved series, missingknowledge_timefor OVERLAPPING, illegal type changes, cycle-creatingmove_to,List(Utf8)manifest paths (useUtf8joined with/), uuid-already-exists onregister_treeRuntimeError— time-seriesread/write/read_relativeon a txn-bound scope (call them outside thewith-block)IncompatibleUnitError— unit conversion failed due to dimensionality mismatch
Best Practices
Always use timezone-aware UTC datetimes. Naive timestamps raise.
from datetime import UTC, datetime good = datetime(2026, 1, 1, 12, tzinfo=UTC)
Declare series with the tree, not later. Inline metadata-only
TimeSeriesentries on everyElement;register_treeregisters them in the same transaction. Usescope.register_seriesonly for surgical additions.Use the imperative scope ops for one-off edits. UUID identity makes
rename,update,move_to, anddeletesilentUPDATEs — no delete-then-insert dance, no full tree round-trip.Batch related mutations in a transaction. Use
Client.transaction()so a sequence ofrename/update/move_to/delete/add/register_treecalls either all apply together or all roll back. Time-series I/O does not participate — callClient.write()/Client.read()outside thewith-block.Pick a routing column per pipeline. Mixing
pathandnode_uuidin the same manifest raises. Usepathfor human-readable ETL,node_uuid/edge_uuidonce you have the ids.pathvalues areUtf8joined with/(e.g."my-portfolio/Offshore-1/T01").Use ``output=”by_path”`` when downstream code is per-series. Training one model per asset, plotting per-series, or computing per-series statistics is cleaner against the keyed dict than against one long-format frame.
Tag writes with ``workflow_id`` / ``model_name``. Provenance lives in
energydb.runsand is recoverable viaread_runs_for_series().Use ``where(type=…)`` for type-filtered subtree reads. A single fluent call replaces N targeted reads — the manifest pipeline batches resolution and the join in one round-trip.
Call ``invalidate_series_cache()`` only after a cross-process schema change. In-process registrations and deletions are tracked automatically.
Complete Example
A complete workflow from setup to analysis:
from datetime import UTC, datetime, timedelta
import energydb as edb
import pandas as pd
import polars as pl
client = edb.Client()
client.delete()
client.create()
# 1. Declare the portfolio (asset hierarchy + series declarations).
t01 = edb.wind.WindTurbine(
name="T01", lat=55.01, lon=3.02, capacity=3.5, hub_height=80,
timeseries=[
edb.TimeSeries(name="power", unit="MW",
data_type=edb.DataType.ACTUAL),
edb.TimeSeries(
name="power", unit="MW",
data_type=edb.DataType.FORECAST,
timeseries_type=edb.TimeSeriesType.OVERLAPPING,
),
],
)
t02 = edb.wind.WindTurbine(name="T02", capacity=3.5, timeseries=[
edb.TimeSeries(name="power", unit="MW",
data_type=edb.DataType.ACTUAL),
])
site = edb.Site(name="Offshore-1", lat=55.0, lon=3.0, members=[t01, t02])
portfolio = edb.Portfolio(name="my-portfolio", members=[site])
# 2. Persist structure (idempotent).
client.register_tree(portfolio)
# 3. Targeted write — actual power for T01.
start = datetime(2026, 1, 1, tzinfo=UTC)
hours = pd.date_range(start, periods=24, freq="1h", tz="UTC")
df = pd.DataFrame({"valid_time": hours, "value": [2.5 + 0.05 * i for i in range(24)]})
client.get_node("my-portfolio", "Offshore-1", "T01").write(
df, data_type="actual", name="power",
)
# 4. Bulk write — actual power for both turbines via a manifest.
long_df = pl.DataFrame({
"path": ["my-portfolio/Offshore-1/T01"] * 24
+ ["my-portfolio/Offshore-1/T02"] * 24,
"data_type": ["actual"] * 48,
"name": ["power"] * 48,
"valid_time": list(hours) * 2,
"value": [2.5 + 0.05 * i for i in range(24)]
+ [2.7 + 0.05 * i for i in range(24)],
})
run_id = client.write(long_df)
print(f"wrote run_id={run_id}")
# 5. Subtree read — every actual 'power' across the portfolio.
result = client.get_node("my-portfolio").read(
data_type="actual", name="power", start_valid=start,
)
print(result.head())
# 5b. Same read, partitioned per-series for downstream loops.
by_series = client.get_node("my-portfolio").read(
data_type="actual", name="power", output="by_path",
)
for (path, dt, name), sub in by_series.items():
print(f"{path}: {len(sub)} rows")
# 6. Surgical edits — batched in a transaction for atomicity.
with client.transaction() as txn:
txn.get_node("my-portfolio", "Offshore-1").rename("Offshore-Renamed")
txn.get_node("my-portfolio", "Offshore-Renamed", "T01").update(
{"capacity": 4.0},
)
txn.get_node("my-portfolio", "Offshore-Renamed", "T02").delete()
txn.commit()
# 7. Cleanup.
client.delete()