Reference

energydb provides a single Python interface — the Client — with two fluent scopes (NodeScope, EdgeScope), a structured TreeDiff for preview/apply workflows, and SQLAlchemy models that double as the schema source of truth.

Client

The single public entry point. Owns a psycopg connection pool against PostgreSQL (asset hierarchy and series catalog) and an internally-constructed timedb.TimeDBClient against ClickHouse (time-series values).

class energydb.Client(*args: Any, **kwargs: Any)[source]

Bases: object

Synchronous EnergyDB client — a blocking facade over AsyncClient.

Accepts the same constructor arguments as AsyncClient. The connection pool is opened eagerly on construction, so the client is ready to use immediately. Always call close() (or use it as a context manager) to release the pool and stop the background loop.

>>> with Client(pg_conninfo=..., ch_url=...) as client:
...     client.create_node(node_type="site", name="S1")
...     row = client.get_node(uuid=...).get_raw()
__init__(*args: Any, **kwargs: Any) None[source]
close() None[source]

Close the connection pool and stop the background event loop.

Write Results

Returned by Client.write, NodeScope.write, and EdgeScope.write. Subclasses int (the run_id) and carries written / skipped row counts.

class energydb.WriteResult(run_id: int, written: int, skipped: int)[source]

Bases: int

The run_id (an int) carrying row counts from a write.

Subclasses int so existing callers that treat the return value as a run_id keep working unchanged; written / skipped ride along as attributes, and .run_id reads as the int value.

Fluent Scopes

client.get_node(...) and client.get_edge(...) return lazy scopes. Path / filter accumulation does not hit the database; terminal operations (.read(), .write(), .get(), .children(), .rename(), .delete(), .register_series(), …) resolve in one indexed SQL query.

class energydb.NodeScope(client: AsyncClient, *, node_uuid: UUID | None = None, path: Path = (), where_filters: dict[str, Any] | None = None, txn: Transaction | None = None)[source]

Bases: _BaseScope

Accumulated scope for navigating and operating on a single node.

Identity is the uuid. _path and _node_uuid accumulate as the user calls .get_node(...); resolution happens on the next terminal call.

__repr__() str[source]

Plain-text repr — no I/O. Shows accumulated path, uuid, filters, txn binding.

async add(edm_obj, *, dry_run: bool = False) NodeScope | TreeDiff[source]

Add a new child node (or subtree) under this scope.

Sugar for register_tree(edm_obj, under=<this scope>). Returns a NodeScope pointing at the added root, or a TreeDiff when dry_run=True. Inherits create-only semantics from Client.register_tree(): raises if any UUID in the payload already exists.

Inside client.transaction() the insert participates in the transaction and shows up in txn.preview(); dry_run=True is not supported inside a transaction.

async children(*, type: str | None = None) list[dict][source]

Direct children of this node only (one level). Optional type filter.

One round-trip: the scope resolve rides the same statement, and the LEFT JOIN keeps the root row so a missing node (raise / empty per addressing) is distinguishable from a childless one (empty).

async descendants(*, type: str | None = None) list[dict][source]

Every node in the subtree rooted at this node, excluding the node itself (recursive). Optional type filter.

One round-trip; the LEFT JOIN keeps the root so a missing node is distinguishable from a childless one. An absolute-path scope knows the prefix client-side, so it goes in as an escaped bind param and PG extracts the literal prefix at plan time (Index Scan on ix_node_path_prefix); uuid-addressed scopes derive the prefix from the root row inside the statement (catalog-wide scan).

get_node(*names_or_path, uuid: UUID | None = None) NodeScope[source]

Lazy navigation. Accepts a /-joined string, variadic names, a tuple/list, or uuid=.

scope.get_node("Site/T01") — canonical /-joined string scope.get_node("Site", "T01") — variadic — equivalent scope.get_node(("Site","T01")) — tuple form scope.get_node(uuid=...) — replace scope with absolute uuid

async get_raw() dict | None[source]

Fetch this node as a raw dict, without EDM reconstruction.

Returns {uuid, node_type, name, data, parent_uuid, path} or None if the uuid-addressed node does not exist (a path-addressed miss raises, matching the resolve contract). Use for generic node types (any node_type string), where get() would raise on an unregistered EDM type.

async move_to(target: NodeScope | tuple[str, ...] | list[str] | str, *, dry_run: bool = False) TreeDiff | None[source]

Re-parent this node to target.

target is a NodeScope, a /-joined string ("P/Site"), or a tuple/list of segments. The node’s uuid (and its series) stays attached. The (parent_uuid, name) unique constraint surfaces destination-name collisions as a Postgres error.

Rejects re-parenting into self or any descendant — that would create a cycle in the parent chain.

async path() tuple[str, ...][source]

Return the resolved path of the scope’s node.

async update(data: dict, *, replace_data: bool = False, dry_run: bool = False) TreeDiff | None[source]

Patch the node’s JSONB data column.

Default is a shallow merge (Postgres data = data || %s) — top-level keys in data overwrite existing keys; nested objects are replaced, not deep-merged. Pass replace_data=True to fully replace the row’s data instead. Renames go through rename().

where(*, type: str | None = None, name: str | None = None, **property_filters) NodeScope[source]

Lazy subtree filter — narrows the current scope to nodes matching the given type / name / data-property predicates. Composes with .node() and resolves at the next terminal call.

class energydb.EdgeScope(client: AsyncClient, *, edge_uuid: UUID | None = None, from_path: Path | None = None, to_path: Path | None = None, edge_type: str | None = None, txn: Transaction | None = None)[source]

Bases: _BaseScope

Scope for operating on a single edge.

Identified by uuid or by the (from_path, to_path, edge_type) triple.

__repr__() str[source]

Plain-text repr — no I/O.

async get_raw() dict | None[source]

Fetch this edge as a raw dict, without EDM reconstruction.

Returns {uuid, edge_type, name, data, from_node_uuid, to_node_uuid} or None if the uuid-addressed edge does not exist (a triple-addressed miss raises, matching the resolve contract). The light way to fetch an edge’s uuid, mirroring NodeScope.get_raw(): no EDM reconstruction, so it works for any edge_type string where get() would raise on an unregistered EDM type.

async move_to(*, from_node: NodeScope | tuple[str, ...] | list[str], to_node: NodeScope | tuple[str, ...] | list[str], dry_run: bool = False) TreeDiff | None[source]

Re-point this edge to a new (from_node, to_node) pair.

The edge’s uuid (and its series) stays attached. The (edge_type, from_node_uuid, to_node_uuid) unique constraint surfaces collisions with an existing edge as a Postgres error.

async update(data: dict, *, replace_data: bool = False, dry_run: bool = False) TreeDiff | None[source]

Patch the edge’s JSONB data column.

Default is a shallow merge (Postgres data = data || %s); pass replace_data=True to fully replace the row’s data. Renames go through rename(); endpoint changes through move_to().

Transactions

client.transaction() returns a Transaction context manager that batches structure mutations into one atomic commit. Time-series read / write / read_relative on a txn-bound scope raise RuntimeError — they do not participate in the PG transaction.

class energydb.Transaction(client: AsyncClient)[source]

Bases: object

Context manager wrapping a single pool connection for atomic batches.

Mid-transaction reads see the transaction’s own uncommitted writes (single physical connection). Time-series I/O — scope.write(df, ...) / scope.read(...) — does not participate in the PG transaction and is rejected with a RuntimeError on a txn-bound scope: call Client.write() / Client.read() directly outside the transaction instead.

__repr__() str[source]

Plain-text repr — no I/O. Shows state + pending-change counts.

async commit() None[source]

Commit the transaction. Required before exiting the with-block.

preview() TreeDiff[source]

Return a TreeDiff aggregating every change so far.

Repeated mutations on the same uuid appear as multiple entries — no collapsing is done. The result is read-only; call again to re-snapshot after additional mutations.

async register_tree(edm_obj, *, under: Path | list[str] | str | None = None) UUID[source]

Create a new tree (or subtree) inside this transaction.

Mirrors Client.register_tree()’s create-only semantics, but reuses the transaction’s connection and extends the change log so the inserts show up in preview().

Diff Types

Returned by client.register_tree(..., dry_run=True) so callers can preview structural changes before applying them.

class energydb.TreeDiff(node_changes: list[NodeChange] = <factory>, edge_changes: list[EdgeChange] = <factory>)[source]

Bases: object

Structured diff between a target EDM tree and the persisted subtree.

Two flat lists of NodeChange / EdgeChange records. Convenience properties (inserts, deletes, renames, moves, updates) bin the changes by kind for callers that want to render or inspect specific subsets.

property has_changes: bool

True if any change exists.

property node_data_edits: list[NodeChange]

Node updates that changed only data (no rename / no move).

property node_updates: list[NodeChange]

All node updates (renames, moves, and/or data edits).

render(file: IO[str] | None = None) None[source]

Render the diff as a tree-shaped textual preview.

Output format:

Portfolio P
├── ~ Site OldName → NewName               [rename]
│   ├── + WindTurbine T03 (capacity=4.0)   [insert]
│   ├──   WindTurbine T01                  [unchanged]
│   ├── ~ WindTurbine T02                  [update: capacity 3.5 → 4.0]
│   └── - Battery B1                       [delete]
└── → Site Other                           [moved from <old_parent>]
edges:
  + Line 'Cable-1' BusA → BusB             [insert]
class energydb.NodeChange(old: SnapshotT | None, new: SnapshotT | None)[source]

Bases: _BaseChange[NodeSnapshot]

A single node-level diff entry.

class energydb.EdgeChange(old: SnapshotT | None, new: SnapshotT | None)[source]

Bases: _BaseChange[EdgeSnapshot]

A single edge-level diff entry.

class energydb.NodeSnapshot(uuid: UUID, node_type: str, name: str, parent_uuid: UUID | None, data: dict[str, Any])[source]

Bases: object

One node row’s content.

class energydb.EdgeSnapshot(uuid: UUID, edge_type: str, name: str | None, from_node_uuid: UUID, to_node_uuid: UUID, data: dict[str, Any])[source]

Bases: object

One edge row’s content.

Exceptions

exception energydb.IncompatibleUnitError[source]

Raised when units cannot be converted to each other.

Time-Series Declarations

TimeSeries lives in timedatamodel and is re-exported from energydb for convenience:

from energydb import DataType, TimeSeries, TimeSeriesType

A metadata-only TimeSeries (constructed with df=None) declares a series’s identity (name, unit, data_type) and its temporal shape (timeseries_type: FLAT or OVERLAPPING). Attach such declarations to any Element via the timeseries=[...] constructor kwarg; register_tree persists them alongside the structure.

Schema (SQLAlchemy Models)

All tables live in the energydb PostgreSQL schema. The SQLAlchemy models are the single source of truth — no raw SQL files. Platform code imports energydb.models.Base for Alembic migrations.

SQLAlchemy declarative models for EnergyDB PostgreSQL tables.

These models are the single schema source of truth — Alembic-friendly. They live in the schema named by ENERGYDB_SCHEMA (default public). The partial unique index on root names is declared in Node.__table_args__; series immutability is enforced in Python (see energydb.series.register_series()), not by a DB trigger.

UUID is the primary identity for every row in node and edge. parent_uuid and edge.from_node_uuid / to_node_uuid are FKs by UUID — the application Reference holds a UUID and writes it directly into the FK column, no translation step. series.series_id stays BIGINT (it’s timedb-internal, not an EDM identity).

Retention tier names are owned by timedb.RETENTION_TIERS; energydb does not encode them in a CHECK constraint, so adding a tier in timedb does not require an energydb migration.

class energydb.models.Edge(**kwargs)[source]

Bases: Base

class energydb.models.Node(**kwargs)[source]

Bases: Base

class energydb.models.Run(**kwargs)[source]

Bases: Base

Run metadata. run_id is client-generated (uuid7 → UInt64 truncate), so writes don’t wait on a PG allocation round-trip.

class energydb.models.Series(**kwargs)[source]

Bases: Base

Polymorphic series owned by either a node or an edge (exactly one).

retention, canonical_unit, and the owner columns are immutable after insert (enforced in Python by register_series). timeseries_type is mutable — a series can legitimately transition from flat to overlapping if the producer changes behavior.

series_id stays BIGINT — it’s the timedb-internal handle and never leaves the energydb / timedb pair.

The energydb.series table is polymorphic: each row is owned by exactly one of node_uuid / edge_uuid (DB CHECK enforces). The series_id primary key stays BIGINT — it’s the timedb-internal handle. Identity for nodes and edges is a UUID primary key, matching the in-memory Element.id.