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(*, pg_conninfo: str | None = None, ch_url: str | None = None)[source]

Bases: object

Client for energy assets, hierarchy, and time series.

Owns the psycopg connection pool (used for all PG ops) and constructs a TimeDBClient for ClickHouse I/O.

__init__(*, pg_conninfo: str | None = None, ch_url: str | None = None)[source]

Construct a client.

__repr__() str[source]

Repr with credentials stripped from the DSN.

Shows scheme + host(:port) + db; the userinfo segment (user:pass) is replaced with ***. Pure formatting — no I/O.

create() None[source]

Create PG schema + CH tables.

Schema is defined entirely by SQLAlchemy models in energydb.models — including the energydb schema, the partial unique index on root names, and the immutability trigger on series. No raw SQL.

create_edge(edm_obj) UUID[source]

Upsert an edge between two existing nodes. Idempotent.

The edge’s Reference endpoints (from_element / to_element) carry the endpoint UUIDs directly — no path resolution. The endpoints must already exist as nodes; the FK constraint will fail otherwise.

For edges that are part of a tree, prefer register_tree() — it walks the structure and validates endpoints against the tree’s index in one pass.

delete() None[source]

Drop PG schema (CASCADE) and CH tables.

get_edge(from_path: tuple[str, ...] | list[str] | str | None = None, to_path: tuple[str, ...] | list[str] | str | None = None, *, type: str | None = None, uuid: UUID | None = None) EdgeScope[source]

Return an EdgeScope by uuid or by (from_path, to_path, type).

from_path / to_path accept the canonical /-joined string form ("P/Site/T01") or a tuple/list of segments. Terminate with .get() to fetch the EDM edge eagerly.

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

Return a NodeScope for a node or subtree.

client.get_node("P/Site/T01") — canonical /-joined string client.get_node("P", "Site", "T01") — variadic — equivalent client.get_node(("P", "Site", "T01")) — tuple/list path client.get_node(uuid=...) — absolute by uuid

/ is reserved as the path separator; names containing / are rejected at registration time. Empty segments (leading, trailing, or doubled /) raise ValueError.

Terminate the chain with .get() to fetch the EDM object, .read() for time-series data, .where(...) to filter a subtree, etc.

get_tree(*names_or_path, uuid: UUID | None = None, include_series: bool = False)[source]

Reconstruct the full EDM subtree rooted at the given node.

With include_series=True, every reconstructed node has its registered series attached as metadata-only TimeSeries entries (df=None) on timeseries.

Edges are intentionally not attached to the returned tree. The result is a node-only subtree walked via parent_uuid. Edges (and their series) live alongside nodes in the schema but outside the tree shape — query them separately with get_edge() or query_edges().

query_edges(*, type: str | None = None, within: tuple[str, ...] | list[str] | str | UUID | None = None, **property_filters) list[source]

Return matching edges as a flat list of EDM objects.

within (/-joined string "P/Site", path tuple/list of segments, or a UUID) restricts to edges where either endpoint is in that subtree.

query_nodes(*, type: str | None = None, within: tuple[str, ...] | list[str] | str | UUID | None = None, **property_filters) list[source]

Return matching nodes as a flat list of EDM objects.

within accepts a /-joined string ("P/Site"), a path tuple/list of segments, or a UUID.

read(df: DataFrame | DataFrame, *, unit: str | None = None, start_valid: datetime | None = None, end_valid: datetime | None = None, start_known: datetime | None = None, end_known: datetime | None = None, include_updates: bool = False, include_knowledge_time: bool = False, output: Literal['frame', 'by_path'] = 'frame', backend: Literal['polars', 'pandas'] = 'polars') DataFrame | DataFrame | dict[SeriesKey, DataFrame] | dict[SeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame][source]

Bulk read via manifest. Detects edge vs node routing automatically.

Accepts pandas or polars on input. Output shape:

  • output="frame" (default): a single DataFrame with columns (path, data_type, name, valid_time, value, …) for node-routed reads, or (from_path, to_path, edge_type, data_type, name, valid_time, value, …) for edge-routed reads. path / from_path / to_path are Utf8 joined with /. Optional columns appear when include_knowledge_time / include_updates are set.

  • output="by_path": a dict keyed by SeriesKey (node-routed: path, data_type, name) or EdgeSeriesKey (edge-routed: from_path, to_path, edge_type, data_type, name) with per-series DataFrames carrying only the data columns (valid_time, value, plus opt-in time/audit columns). Keys are NamedTuples — positional access (result[(path, dt, name)]) and attribute access (key.path) both work. Each sub-frame is sorted by valid_time ascending; secondary sort keys are knowledge_time and/or change_time when requested.

backend="polars" (default) returns polars frames; backend="pandas" converts at the boundary. Internal identifiers (series_id, node_uuid, edge_uuid) are never exposed on the result.

read_relative(df: DataFrame | DataFrame, *, unit: str | None = None, output: Literal['frame', 'by_path'] = 'frame', backend: Literal['polars', 'pandas'] = 'polars', **td_kwargs) DataFrame | DataFrame | dict[SeriesKey, DataFrame] | dict[SeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame][source]

Bulk relative read via manifest.

See read() for the output / backend contract. **td_kwargs are forwarded to timedb.TimeDBClient.read_relative(); see that signature for accepted arguments (window selectors, etc.).

read_runs_for_series(*, series_id: int) list[dict[str, Any]][source]

Return runs that wrote data for a given series_id, latest first.

register_tree(edm_obj, *, under: tuple[str, ...] | list[str] | str | None = None, dry_run: bool = False) UUID | TreeDiff[source]

Persist an EDM tree’s structure: nodes, edges, series declarations.

Create-only. Raises ValueError if any UUID in the payload already exists in the DB; modify existing rows via scope mutators (NodeScope.rename(), .update, .delete, .move_to) or batch them with transaction().

dry_run=True returns the computed TreeDiff without committing — the transaction is rolled back so no DB state changes.

Inline TimeSeries.df data is rejected: write data separately via write() against a manifest. under selects the parent under which the tree’s root is grafted; None means create at root. Raises if under points at a non-existent parent.

Series declarations attached to nodes/edges on the tree are registered alongside their owners but are not represented in the returned TreeDiff. Adding a series to a node that already exists in the DB is not supported here (the create-only pre-check rejects the whole payload); use NodeScope.register_series() / EdgeScope.register_series() instead.

Returns the uuid of the tree’s root, except when dry_run=True (which returns the TreeDiff).

transaction() Transaction[source]

Open an atomic batch of scope mutations.

Returns a Transaction context manager. Mutations executed through txn.get_node(...) / txn.get_edge(...) / txn.register_tree(...) apply immediately to the open transaction’s connection but are not committed until Transaction.commit() is called explicitly. Exit without commit raises and rolls back.

Time-series I/O (scope.write(df, ...) / scope.read(...)) inside a transaction does not participate in atomicity — it executes immediately against the pool / ClickHouse.

write(df: DataFrame | DataFrame, *, knowledge_time: datetime | None = None, run_id: int | None = None, workflow_id: str | None = None, model_name: str | None = None, run_start_time: datetime | None = None, run_finish_time: datetime | None = None, run_params: dict | None = None) int[source]

Bulk-write timeseries data via a routing manifest.

df is a pandas or polars DataFrame carrying one routing column (node_uuid, edge_uuid, or path as Utf8 joined with /, e.g. "my-portfolio/Offshore-1/T01"), plus data_type, name, and the timedb data columns (valid_time, value, optional knowledge_time). Optional unit column triggers per-row unit conversion to each series’s canonical unit.

Series must already be registered (typically via register_tree()). Returns the run_id used.

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: Client, *, 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.

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.

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

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

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.

Materialized-path prefix scan against ix_node_path_prefix — one indexed lookup, no recursive CTE.

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

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.

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

Return the resolved path of the scope’s node.

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: Client, *, 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.

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.

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: Client)[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.

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.

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. The energydb schema, the immutability trigger on series, and the partial unique index on root names are all attached as DDL events on Base.metadata.

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 by DB trigger). 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.