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:
objectClient for energy assets, hierarchy, and time series.
Owns the psycopg connection pool (used for all PG ops) and constructs a
TimeDBClientfor ClickHouse I/O.- __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 theenergydbschema, the partial unique index on root names, and the immutability trigger onseries. No raw SQL.
- create_edge(edm_obj) UUID[source]
Upsert an edge between two existing nodes. Idempotent.
The edge’s
Referenceendpoints (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.
- 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
EdgeScopeby uuid or by(from_path, to_path, type).from_path/to_pathaccept 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
NodeScopefor a node or subtree.client.get_node("P/Site/T01")— canonical/-joined stringclient.get_node("P", "Site", "T01")— variadic — equivalentclient.get_node(("P", "Site", "T01"))— tuple/list pathclient.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/) raiseValueError.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-onlyTimeSeriesentries (df=None) ontimeseries.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 withget_edge()orquery_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 aUUID) 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.
withinaccepts a/-joined string ("P/Site"), a path tuple/list of segments, or aUUID.
- 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_pathareUtf8joined with/. Optional columns appear wheninclude_knowledge_time/include_updatesare set.output="by_path": adictkeyed bySeriesKey(node-routed:path,data_type,name) orEdgeSeriesKey(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 byvalid_timeascending; secondary sort keys areknowledge_timeand/orchange_timewhen 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 theoutput/backendcontract.**td_kwargsare forwarded totimedb.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
ValueErrorif 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 withtransaction().dry_run=Truereturns the computedTreeDiffwithout committing — the transaction is rolled back so no DB state changes.Inline
TimeSeries.dfdata is rejected: write data separately viawrite()against a manifest.underselects the parent under which the tree’s root is grafted;Nonemeans create at root. Raises ifunderpoints 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); useNodeScope.register_series()/EdgeScope.register_series()instead.Returns the
uuidof the tree’s root, except whendry_run=True(which returns theTreeDiff).
- transaction() Transaction[source]
Open an atomic batch of scope mutations.
Returns a
Transactioncontext manager. Mutations executed throughtxn.get_node(...)/txn.get_edge(...)/txn.register_tree(...)apply immediately to the open transaction’s connection but are not committed untilTransaction.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.
dfis a pandas or polars DataFrame carrying one routing column (node_uuid,edge_uuid, orpathasUtf8joined with/, e.g."my-portfolio/Offshore-1/T01"), plusdata_type,name, and the timedb data columns (valid_time,value, optionalknowledge_time). Optionalunitcolumn triggers per-row unit conversion to each series’s canonical unit.Series must already be registered (typically via
register_tree()). Returns therun_idused.
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:
_BaseScopeAccumulated scope for navigating and operating on a single node.
Identity is the
uuid._pathand_node_uuidaccumulate 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 aNodeScopepointing at the added root, or aTreeDiffwhendry_run=True. Inherits create-only semantics fromClient.register_tree(): raises if any UUID in the payload already exists.Inside
client.transaction()the insert participates in the transaction and shows up intxn.preview();dry_run=Trueis 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, oruuid=.scope.get_node("Site/T01")— canonical/-joined stringscope.get_node("Site", "T01")— variadic — equivalentscope.get_node(("Site","T01"))— tuple formscope.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.targetis aNodeScope, a/-joined string ("P/Site"), or a tuple/list of segments. The node’suuid(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.
- update(data: dict, *, replace_data: bool = False, dry_run: bool = False) TreeDiff | None[source]
Patch the node’s JSONB
datacolumn.Default is a shallow merge (Postgres
data = data || %s) — top-level keys indataoverwrite existing keys; nested objects are replaced, not deep-merged. Passreplace_data=Trueto fully replace the row’sdatainstead. Renames go throughrename().
- 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:
_BaseScopeScope for operating on a single edge.
Identified by
uuidor by the(from_path, to_path, edge_type)triple.- 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
datacolumn.Default is a shallow merge (Postgres
data = data || %s); passreplace_data=Trueto fully replace the row’sdata. Renames go throughrename(); endpoint changes throughmove_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:
objectContext 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: callClient.write()/Client.read()directly outside the transaction instead.- preview() TreeDiff[source]
Return a
TreeDiffaggregating 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 inpreview().
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:
objectStructured diff between a target EDM tree and the persisted subtree.
Two flat lists of
NodeChange/EdgeChangerecords. Convenience properties (inserts,deletes,renames,moves,updates) bin the changes by kind for callers that want to render or inspect specific subsets.- 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.
Exceptions
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.Run(**kwargs)[source]
Bases:
BaseRun metadata.
run_idis client-generated (uuid7 → UInt64 truncate), so writes don’t wait on a PG allocation round-trip.
- class energydb.models.Series(**kwargs)[source]
Bases:
BasePolymorphic 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_typeis mutable — a series can legitimately transition from flat to overlapping if the producer changes behavior.series_idstays 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.