Reference

energydb provides a single Python interface — the Client and its async twin AsyncClient — 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.

client.namespace(ns) works here too: the reflection proxy wraps the returned AsyncClient view, so the result is a sync, namespace-bound view sharing this client’s pool (and, like the async view, refuses lifecycle/schema operations). 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.

Idempotent: a second call is a no-op, so calling it explicitly before (or after) a with block exits is safe.

Client is a thin blocking facade: it forwards every attribute to the AsyncClient below, so the full method list is documented there once. Each method listed under AsyncClient exists on Client too, with an identical signature and no await:

client.register_tree(portfolio)          # Client   — blocks
await aclient.register_tree(portfolio)   # AsyncClient

The same holds for the scopes and the transaction: client.get_node(...) returns a synchronous view of NodeScope.

class energydb.AsyncClient(*, pg_conninfo: str | None = None, ch_url: str | None = None)[source]

Bases: object

Async-native client for energy assets, hierarchy, and time series.

Owns the psycopg AsyncConnectionPool (used for all PG ops) and constructs a TimeDBClient for ClickHouse I/O. Every PG round-trip is awaited; the ClickHouse leg (sync clickhouse-connect) is offloaded to a worker thread. Synchronous callers use energydb.Client, a thin blocking facade over this class.

await client.open() before first use, and await client.close() when done, or use it as an async context manager:

>>> async with AsyncClient(pg_conninfo=..., ch_url=...) as client:
...     await client.create_node(node_type="site", name="S1")
__init__(*, pg_conninfo: str | None = None, ch_url: str | None = None)[source]

Construct a client.

Reads run the PG meta-resolve and the CH value read in parallel whenever the read is expressible over the ClickHouse engine table (provisioned by create() for fresh DBs, or explicitly by setup_ch_meta_engine()); anything else, and any engine failure, uses the sequential path, with identical results. Set ENERGYDB_DISABLE_ENGINE=1 to force sequential reads for the whole session (ops kill-switch; also what benchmarks use for before/after).

async close() → None[source]

Close the PostgreSQL connection pool and the ClickHouse client.

Root-client only: calling it on a namespace() view raises ValidationError, since the view shares the root’s pool.

async create() → None[source]

Create PG schema + CH tables, and provision the CH meta engine table.

Schema is defined by the SQLAlchemy models in energydb.models (the series_meta view rides on the DDL events), created in a worker thread because create_all and TimeDB’s create are synchronous.

The engine table is best-effort: a CH role that cannot create PostgreSQL() engine tables gets a logged warning and reads fall back to the sequential path. Same fallback, quietly, if the PG DSN has no TCP host as seen from ClickHouse (a Unix-socket-only DSN, e.g. from postgresql:///db?host=/run/postgresql): the fast, engine-backed read path needs PostgreSQL reachable over TCP from ClickHouse, so set ENERGYDB_CH_PG_HOST if the DSN’s own host is socket-only or not resolvable from ClickHouse’s network. setup_ch_meta_engine() is the explicit, raising alternative.

For production, set ENERGYDB_CH_PG_COLLECTION to a ClickHouse named collection holding the PostgreSQL connection; otherwise the credentials are inlined into the engine table’s DDL (and a warning says so).

Raises ConfigurationError on PostgreSQL older than 15: the edge_uniq multigraph key needs UNIQUE NULLS NOT DISTINCT.

async 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, with 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.

async create_node(*, node_type: str, name: str, data: dict | None = None, parent: UUID | tuple[str, ...] | list[str] | str | None = None, uuid: UUID | None = None) → UUID[source]

Create a single node from a type slug + JSONB data, with no EDM class.

Generic counterpart to register_tree(): node_type is stored as a free-form string and data verbatim, bypassing EnergyDataModel (de)serialization. parent selects the parent node (UUID or path); None creates a root. uuid is minted (uuid7) when omitted. Read these nodes back with get_node_raw() / get_subtree_raw() or NodeScope.children(), not the EDM readers, which require a registered type.

async delete() → None[source]

Drop EnergyDB’s tables and CH tables.

With a named schema, drops the whole schema (CASCADE). With the default public schema (SCHEMA is None), drops only EnergyDB’s own four tables, never the shared public schema, which would take the host application’s tables with it.

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

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

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.

name picks one of several parallel edges sharing the triple (the six circuits of a double-circuit corridor, say). Without it a triple that matches exactly one edge resolves, and one that matches several raises AmbiguousEdgeError listing the candidates rather than picking one.

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.

async get_node_raw(node_uuid: UUID) → dict | None[source]

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

Returns {uuid, node_type, name, data, parent_uuid} or None if the node does not exist. Safe for any node_type string, unlike get_node() / get_tree().

async get_subtree_raw(root_uuid: UUID) → list[dict][source]

Return the node + every descendant as raw dicts (no EDM reconstruction).

One round-trip: materialized-path prefix scan with the prefix derived from the root row inside the statement. Each dict is {uuid, node_type, name, data, parent_uuid, path}. Includes the root itself; empty list if the root does not exist.

async 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().

async list_nodes_raw(*, node_type: str | list[str] | None = None, parents: list[UUID] | None = None, after: tuple[str, UUID] | None = None, limit: int | None = None) → list[dict][source]

List raw node rows with SQL-side filtering and keyset pagination.

Filters compose with AND: node_type (one string or a list), parents (direct children of any of the given nodes). On a namespaced view the rows are additionally constrained to the view’s namespace explicitly, independent of whether RLS policies are installed. after is a (name, uuid) keyset cursor matching the ORDER BY name, uuid::text ordering; limit caps the page. Row shape matches get_node_raw() / get_subtree_raw().

async list_series(owner_uuid: UUID, *, owner_col: str = 'node_uuid') → list[dict][source]

List the series catalog owned by a node (or edge).

Returns {series_id, name, data_type, canonical_unit, timeseries_type, description} per series. owner_col is "node_uuid" (default) or "edge_uuid".

series_id is the timedb-internal handle (the same value NodeScope.register_series() returns) and makes this the reverse lookup from (owner, data_type, name). It is an input to lower-level timedb APIs, not a secret; read results still never carry it.

namespace(ns: str) → AsyncClient[source]

Return a view of this client bound to one namespace.

The view shares the parent’s connection pool and ClickHouse client and is a cheap, disposable dict-copy: create one per request. Every PG round-trip through the view binds the energydb.namespace GUC (see _conn() / _read_conn()), which row-level security policies use to filter every table to that namespace and the columns’ server defaults use to stamp writes. Lifecycle and schema operations (open(), close(), create(), delete(), setup_ch_meta_engine()) stay with the root client and raise on a view.

Engine-parallel reads are disabled on views: the ClickHouse meta engine table reads PG with its own RLS-bypassing credentials, so views always take the sequential resolve until its predicate carries the namespace. Results are identical and namespace-enforced.

async open() → None[source]

Open the async connection pool. Await once before first use.

async 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. One round-trip either way: the subtree is matched by path prefix inside the statement (DISTINCT collapses edges reached via both endpoints).

async 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. One round-trip either way: the within subtree is matched by path prefix inside the statement (filters ride on the join), not resolved separately.

async 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', on_missing: Literal['raise', 'skip'] = 'raise') → DataFrame | DataFrame | dict[SeriesKey, DataFrame] | dict[SeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | ReadResult[source]

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

Routing is chosen from the columns present (exactly one route):

  • path: node series by materialized path (Utf8 joined with /).

  • node_uuid / edge_uuid: series by owner uuid.

  • from_path + to_path + edge_type: edge series by their endpoint paths and type (all three required together), resolved server-side the same way node path is. Matches the edge output columns, so an edge read’s output can be fed back as a manifest without a UUID-resolution round-trip.

  • edge_name: optional fourth column on that route, picking one of several parallel edges sharing a triple (null = the unnamed edge). A triple matching more than one edge without it raises AmbiguousEdgeError.

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, edge_name, data_type, name, valid_time, value, …) for edge-routed reads. path / from_path / to_path are Utf8 joined with /; edge_name is the edge’s own name, always present and null for unnamed edges. 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, edge_name, data_type, name), valued by per-series DataFrames carrying only the data columns (valid_time, value, plus opt-in time/audit columns). Keys are NamedTuples, so positional (result[(path, dt, name)]) and attribute (key.path) access both work. Sub-frames are sorted by valid_time ascending, then knowledge_time / change_time when requested.

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

``on_missing`` changes the return type. With the default "raise", an unregistered (owner, data_type, name) triple fails the whole call with SeriesNotFoundError, naming every unresolved triple, and the return value is as described above. With "skip", those triples are dropped and the call returns a ReadResult of (data, missing), reporting them there. Only unregistered series are affected: a structurally invalid manifest (missing or ambiguous routing column, wrong dtype, null routing value) raises either way.

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

Bulk relative read via manifest.

See read() for the output / backend contract, and for on_missing, which switches the return type to ReadResult when set to "skip", exactly as it does there. **td_kwargs are forwarded to timedb.TimeDBClient.read_relative(); see that signature for accepted arguments (window selectors, etc.).

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

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

async 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 on the tree are registered alongside their owners but do not appear in the returned TreeDiff. Adding a series to a node that already exists in the DB is not supported here, since the create-only pre-check rejects the whole payload; use NodeScope.register_series() / EdgeScope.register_series().

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

async setup_ch_meta_engine() → None[source]

Provision the ClickHouse ↔ PG metadata bridge for concurrent reads.

Idempotent. (Re)creates the PG series_meta view and the ClickHouse PostgreSQL() engine table over it (see energydb._ch_meta_engine for the credential/vantage resolution). Unlike create()’s best-effort provisioning this raises on failure, and it clears the session’s engine-unavailable degrade flag; call it to re-enable concurrent after fixing engine infrastructure.

Set ENERGYDB_CH_PG_COLLECTION to a ClickHouse named collection for production deployments; without it the PostgreSQL password is inlined into the DDL and readable via SHOW CREATE TABLE (warned about at provisioning time).

Raises ConfigurationError if the PG DSN has no TCP host as seen from ClickHouse (see create()); set ENERGYDB_CH_PG_HOST to fix it.

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.

async 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, skip_unchanged: bool = False, unchanged_scope: Literal['valid_time', 'knowledge_time', 'auto'] = 'auto') → WriteResult[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.

skip_unchanged drops rows whose latest stored value is unchanged before the insert. unchanged_scope picks the comparison key:

  • "auto" (default): per series, by its registered type. FLAT compares per valid_time, OVERLAPPING per (valid_time, knowledge_time), so one call handles a mixed manifest. Identical to "valid_time" for a FLAT-only manifest.

  • "knowledge_time": that key uniformly.

  • "valid_time": that key uniformly. Raises UnchangedScopeError if the manifest contains OVERLAPPING series, since it would drop their republications.

Series must already be registered (typically via register_tree()). Returns a WriteResult, an int run_id carrying written / skipped counts.

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.

property run_id: int

the same value as int(result).

Type:

The run id for this write

Returned by Client.read / Client.read_relative only when on_missing="skip" is passed; the default returns the data bare.

class energydb.ReadResult(data: pl.DataFrame | pd.DataFrame | dict[SeriesKey, pl.DataFrame] | dict[SeriesKey, pd.DataFrame] | dict[EdgeSeriesKey, pl.DataFrame] | dict[EdgeSeriesKey, pd.DataFrame], missing: pl.DataFrame | pd.DataFrame)[source]

Bases: NamedTuple

A read’s data plus the manifest triples that resolved to no series.

Returned by read() / read_relative() only when on_missing="skip"; the default ("raise") returns data bare, so existing callers never see this type.

data is exactly what the same call would return without on_missing (honouring output and backend, including the empty shapes). missing holds the unique unresolvable triples: the manifest’s routing column(s) plus data_type / name, Utf8 throughout (uuids stringified), zero-row with the right schema when everything resolved. It follows backend like data does.

A NamedTuple, so data, missing = await client.read(...) unpacks, mirroring WriteResult’s enriched-but-simple shape.

data: pl.DataFrame | pd.DataFrame | dict[SeriesKey, pl.DataFrame] | dict[SeriesKey, pd.DataFrame] | dict[EdgeSeriesKey, pl.DataFrame] | dict[EdgeSeriesKey, pd.DataFrame]

Alias for field number 0

missing: pl.DataFrame | pd.DataFrame

Alias for field number 1

Reads with output="by_path" return a dict keyed by one of these NamedTuples — node-routed reads by SeriesKey, edge-routed reads by EdgeSeriesKey. Both support positional and attribute access.

class energydb.SeriesKey(path: str, data_type: str, name: str)[source]

Bases: NamedTuple

Typed key for node-routed output="by_path" result dicts.

Tuple-compatible: existing positional access (result[("P/T01", "actual", "power")]) keeps working. New code can use attribute access (key.path, key.data_type, key.name).

data_type: str

Alias for field number 1

name: str

Alias for field number 2

path: str

Alias for field number 0

class energydb.EdgeSeriesKey(from_path: str, to_path: str, edge_type: str, edge_name: str | None, data_type: str, name: str)[source]

Bases: NamedTuple

Typed key for edge-routed output="by_path" result dicts.

Tuple-compatible. Holds the 6-element identity of an edge-attached series, both endpoint paths, the edge type, the edge’s own name (None for an unnamed edge), and the series’s own (data_type, name) pair.

edge_name sits fourth, next to edge_type, so the key reads as edge-identity-then-series-identity. It is what keeps two parallel circuits’ series apart: without it they would collide on one key.

Changed in version 0.11.0: Gained edge_name (5 → 6 fields). Positional unpackers of the old 5-tuple break loudly; keyword/attribute access is unaffected.

data_type: str

Alias for field number 4

edge_name: str | None

Alias for field number 3

edge_type: str

Alias for field number 2

from_path: str

Alias for field number 0

name: str

Alias for field number 5

to_path: str

Alias for field number 1

energydb.find(result: dict, **filters)[source]

Partial-match filter over a by_path result dict.

filters are attribute-name → value pairs matched against SeriesKey / EdgeSeriesKey fields. Returns a list of (key, df) tuples in the result’s iteration order.

edb.find(result, name="power") returns all series named "power" regardless of path / data_type. Unknown attribute names match nothing.

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.

Both scopes share the time-series surface (read, write, read_relative, read_from_meta, register_series, resolve) and add their own structural operations, all listed below.

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 delete(*, dry_run: bool = False) → TreeDiff | None[source]

Delete this node.

Descendants, attached edges, and series declarations go with it via ON DELETE CASCADE; the time-series values already written to ClickHouse are not removed. With dry_run=True nothing is written and a TreeDiff is returned.

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).

async get()[source]

Reconstruct this node as an EnergyDataModel object.

The returned Element keeps the stored UUID, so it round-trips. Series are not attached: use Client.get_tree(include_series=True) for that, or get_raw() for the plain row.

Raises NodeNotFoundError if the path or uuid resolves to nothing.

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 knowledge_times_from_meta(meta: DataFrame, *, start_valid: datetime | None = None, end_valid: datetime | None = None, limit: int = 20) → list[datetime]

Newest-first distinct knowledge_times (“runs”) for the series in meta within the window — one ClickHouse aggregate. Returns up to limit + 1 so callers can detect truncation.

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 read(*, data_type: str | None = None, name: str | None = None, 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]

Read time-series data for this scope.

For NodeScope the manifest spans the resolved subtree; for EdgeScope it’s the single edge. See Client.read() for the output / backend contract. When the scope is engine-expressible (see _engine_meta()) the PG resolve runs in parallel with the CH value read; otherwise (.where() filters, uuid-addressed subtrees, or an unavailable engine) it runs sequentially. Results are identical either way.

async read_from_meta(meta: 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, bucket_us: int | None = None, bucket_dedup: bool = True, 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]

Read timeseries data for a meta frame from resolve(): the ClickHouse leg only, with no further PG round-trip. output / backend follow the read() contract.

Implemented over execute_read() with an instant resolve and no engine predicate (the meta is already exact), so it shares the one read pipeline with everything else.

async read_relative(*, data_type: str, name: str, unit: str | None = None, output: Literal['frame', 'by_path'] = 'frame', backend: Literal['polars', 'pandas'] = 'polars', **td_read_kwargs) → DataFrame | DataFrame | dict[SeriesKey, DataFrame] | dict[SeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame]

Relative-window read for this scope.

**td_read_kwargs are forwarded to timedb.TimeDBClient.read_relative(); see that signature for accepted window-selector arguments.

async register_series(ts_or_name: TimeSeries | str | None = None, *, name: str | None = None, canonical_unit: str | None = None, data_type: str | None = None, timeseries_type: str | None = None, retention: str | None = None, description: str | None = None) → int

Register a time series on this scope’s owner (node or edge).

Accepts a TimeSeries (metadata extracted) or explicit kwargs. When retention is omitted it is derived from timeseries_type: FLAT (actuals) → 'forever', OVERLAPPING (forecasts) → 'medium'.

async rename(new_name: str, *, dry_run: bool = False) → TreeDiff | None[source]

Rename this node in place: same uuid, one UPDATE.

The node’s path and every descendant’s path are rewritten in the same statement. With dry_run=True nothing is written and a TreeDiff of the pending change is returned.

async resolve(*, data_type: str | None = None, name: str | None = None) → DataFrame | None

Resolve this scope to per-series read metadata in one PG round-trip, without reading any timeseries data.

Returns the frame read_from_meta() consumes, one row per series with series_id, canonical_unit, timeseries_type, data_type, name and (for node scopes) the materialized path, or None if nothing matches. Splitting resolve from the read lets a caller authorize or inspect (e.g. by path) before paying for the ClickHouse read; resolve() then read_from_meta() is exactly what read() does in one call (sequential path).

async resolved_stats_from_meta(meta: DataFrame, *, start_valid: datetime | None = None, end_valid: datetime | None = None) → tuple[int, bool]

(max per-series resolved point count, has_versions) for the window — one cheap ClickHouse aggregate, for downsampling decisions.

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().

async valid_range_from_meta(meta: DataFrame) → tuple[datetime, datetime] | None

Overall [min, max] valid_time across the series in meta (a frame from resolve()) — one ClickHouse aggregate, no row reads. None when the series hold no data.

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.

async write(df: DataFrame | DataFrame, *, data_type: str, name: str, unit: str | None = None, 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, skip_unchanged: bool = False, unchanged_scope: Literal['valid_time', 'knowledge_time', 'auto'] = 'auto') → WriteResult

Write time-series data for a single series on this scope’s owner.

Builds a 1-route manifest (owner uuid, data_type, name, plus optional unit) over df (pandas or polars) and delegates to Client.write(). skip_unchanged / unchanged_scope are forwarded; the default "auto" picks the comparison key from this series’ registered type, so an OVERLAPPING series keeps its republications; see Client.write(). Returns a WriteResult, an int run_id carrying written / skipped counts.

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, edge_name: 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, optionally narrowed by edge_name, which is what tells parallel edges of a multigraph apart. A triple matching several edges raises AmbiguousEdgeError on resolution.

__repr__() → str[source]

Plain-text repr: no I/O.

async delete(*, dry_run: bool = False) → TreeDiff | None[source]

Delete this edge and its series declarations.

The endpoint nodes are untouched, and values already in ClickHouse are not removed. With dry_run=True nothing is written and a TreeDiff is returned.

async from_node() → NodeScope[source]

Return a NodeScope on this edge’s source endpoint.

async get()[source]

Reconstruct this edge as an EnergyDataModel object, endpoints included.

Raises EdgeNotFoundError when the uuid or the (from_path, to_path, type) triple matches no edge.

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 knowledge_times_from_meta(meta: DataFrame, *, start_valid: datetime | None = None, end_valid: datetime | None = None, limit: int = 20) → list[datetime]

Newest-first distinct knowledge_times (“runs”) for the series in meta within the window — one ClickHouse aggregate. Returns up to limit + 1 so callers can detect truncation.

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. Landing on a (edge_type, from_node_uuid, to_node_uuid, name) quadruple that is already taken raises AlreadyExistsError; give the edge a distinct name (see rename()) to park two parallel edges on the same endpoint pair.

async read(*, data_type: str | None = None, name: str | None = None, 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]

Read time-series data for this scope.

For NodeScope the manifest spans the resolved subtree; for EdgeScope it’s the single edge. See Client.read() for the output / backend contract. When the scope is engine-expressible (see _engine_meta()) the PG resolve runs in parallel with the CH value read; otherwise (.where() filters, uuid-addressed subtrees, or an unavailable engine) it runs sequentially. Results are identical either way.

async read_from_meta(meta: 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, bucket_us: int | None = None, bucket_dedup: bool = True, 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]

Read timeseries data for a meta frame from resolve(): the ClickHouse leg only, with no further PG round-trip. output / backend follow the read() contract.

Implemented over execute_read() with an instant resolve and no engine predicate (the meta is already exact), so it shares the one read pipeline with everything else.

async read_relative(*, data_type: str, name: str, unit: str | None = None, output: Literal['frame', 'by_path'] = 'frame', backend: Literal['polars', 'pandas'] = 'polars', **td_read_kwargs) → DataFrame | DataFrame | dict[SeriesKey, DataFrame] | dict[SeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame] | dict[EdgeSeriesKey, DataFrame]

Relative-window read for this scope.

**td_read_kwargs are forwarded to timedb.TimeDBClient.read_relative(); see that signature for accepted window-selector arguments.

async register_series(ts_or_name: TimeSeries | str | None = None, *, name: str | None = None, canonical_unit: str | None = None, data_type: str | None = None, timeseries_type: str | None = None, retention: str | None = None, description: str | None = None) → int

Register a time series on this scope’s owner (node or edge).

Accepts a TimeSeries (metadata extracted) or explicit kwargs. When retention is omitted it is derived from timeseries_type: FLAT (actuals) → 'forever', OVERLAPPING (forecasts) → 'medium'.

async rename(new_name: str, *, dry_run: bool = False) → TreeDiff | None[source]

Rename this edge in place: same uuid, one UPDATE.

The name is part of the edge’s unique key, so renaming onto a (edge_type, from, to, name) quadruple that a parallel edge already occupies raises AlreadyExistsError.

With dry_run=True nothing is written and a TreeDiff of the pending change is returned.

async resolve(*, data_type: str | None = None, name: str | None = None) → DataFrame | None

Resolve this scope to per-series read metadata in one PG round-trip, without reading any timeseries data.

Returns the frame read_from_meta() consumes, one row per series with series_id, canonical_unit, timeseries_type, data_type, name and (for node scopes) the materialized path, or None if nothing matches. Splitting resolve from the read lets a caller authorize or inspect (e.g. by path) before paying for the ClickHouse read; resolve() then read_from_meta() is exactly what read() does in one call (sequential path).

async resolved_stats_from_meta(meta: DataFrame, *, start_valid: datetime | None = None, end_valid: datetime | None = None) → tuple[int, bool]

(max per-series resolved point count, has_versions) for the window — one cheap ClickHouse aggregate, for downsampling decisions.

async to_node() → NodeScope[source]

Return a NodeScope on this edge’s target endpoint.

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().

async valid_range_from_meta(meta: DataFrame) → tuple[datetime, datetime] | None

Overall [min, max] valid_time across the series in meta (a frame from resolve()) — one ClickHouse aggregate, no row reads. None when the series hold no data.

async write(df: DataFrame | DataFrame, *, data_type: str, name: str, unit: str | None = None, 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, skip_unchanged: bool = False, unchanged_scope: Literal['valid_time', 'knowledge_time', 'auto'] = 'auto') → WriteResult

Write time-series data for a single series on this scope’s owner.

Builds a 1-route manifest (owner uuid, data_type, name, plus optional unit) over df (pandas or polars) and delegates to Client.write(). skip_unchanged / unchanged_scope are forwarded; the default "auto" picks the comparison key from this series’ registered type, so an OVERLAPPING series keeps its republications; see Client.write(). Returns a WriteResult, an int run_id carrying written / skipped counts.

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.

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

Return an EdgeScope bound to this transaction.

Same addressing as Client.get_edge (name= included, for parallel edges), with the same transaction semantics as get_node().

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

Return a NodeScope bound to this transaction.

Same addressing as Client.get_node, but every mutation runs on the transaction’s connection and stays uncommitted until commit(). Time-series read / write / read_relative on the returned scope raise RuntimeError; they do not participate in the PostgreSQL transaction.

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>)

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 edge_deletes: list[EdgeChange]

Edges removed by this diff.

property edge_inserts: list[EdgeChange]

Edges created by this diff.

property edge_updates: list[EdgeChange]

Edges whose endpoints, name, or data changed.

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_deletes: list[NodeChange]

Nodes removed by this diff.

property node_inserts: list[NodeChange]

Nodes created by this diff.

property node_moves: list[NodeChange]

Node updates that changed the parent (may also have been renamed).

property node_renames: list[NodeChange]

Node updates that changed the name (may also have moved).

property node_updates: list[NodeChange]

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

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

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)

Bases: _BaseChange[NodeSnapshot]

A single node-level diff entry.

property data_changed: bool

True when this is an update whose data payload differs. Always False for inserts and deletes.

property display_name: str

The node’s name, for rendering the diff.

property display_type: str

The node’s node_type, for rendering the diff.

property kind: str

"insert" (no old), "delete" (no new), or "update" (both present).

property moved: bool

True when this update re-parented the node (parent_uuid changed).

property renamed: bool

True when this update changed the node’s name.

property uuid: UUID

The changed element’s UUID, taken from whichever of new / old is present (they always share it).

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

Bases: _BaseChange[EdgeSnapshot]

A single edge-level diff entry.

property data_changed: bool

True when this is an update whose data payload differs. Always False for inserts and deletes.

property display_name: str

The edge’s name, falling back to its edge_type when unnamed.

property display_type: str

The edge’s edge_type, for rendering the diff.

property endpoints_changed: bool

True when this update moved either endpoint of the edge.

property kind: str

"insert" (no old), "delete" (no new), or "update" (both present).

property uuid: UUID

The changed element’s UUID, taken from whichever of new / old is present (they always share it).

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

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])

Bases: object

One edge row’s content.

Exceptions

Every exception energydb raises deliberately derives from EnergyDBError. Every raisable subclass of it also derives from ValueError, so broad except ValueError handlers keep catching them (the EnergyDBError base itself is never raised directly and does not subclass ValueError). The not-found family carries structured identifier fields so callers can react programmatically instead of matching message text. All names are re-exported from the package root. See the SDK error-handling guide for usage.

Typed exception hierarchy for energydb.

Every exception energydb raises deliberately derives from EnergyDBError. Every class that replaced a bare ValueError raise site also derives from ValueError, so any existing except ValueError handler keeps working unchanged; the taxonomy is additive by construction.

The not-found family carries structured identifier fields (path, uuid, route, missing, …) so callers, API servers in particular, can react programmatically instead of matching message text. Fields are keyword-only, stored under their own name, and default to None when the raise site doesn’t know them. message stays args[0], so str(e) matches the bare-ValueError form.

This module sits at the bottom of the package dependency graph: it imports nothing from the rest of energydb at runtime, so every other module can import it freely. IncompatibleUnitError keeps its definition in energydb.units (import stability) and is re-exported here lazily via PEP 562: units imports this module for EnergyDBError, so a module-level re-export would be a cycle.

exception energydb.errors.EnergyDBError[source]

Bases: Exception

Base class for every exception energydb raises deliberately.

exception energydb.errors.NotFoundError[source]

Bases: EnergyDBError, ValueError

An addressed entity does not exist.

exception energydb.errors.NodeNotFoundError(message: str, *, path: str | None = None, uuid: UUID | None = None)[source]

Bases: NotFoundError

A node addressed by path or by uuid does not exist.

path is the /-joined path that was addressed; uuid the addressed node uuid. Either may be None: the site addressed the other way, or (bulk path resolution) several paths missed at once and no single one identifies the failure.

exception energydb.errors.EdgeNotFoundError(message: str, *, uuid: UUID | None = None, from_path: str | None = None, to_path: str | None = None, edge_type: str | None = None, name: str | None = None)[source]

Bases: NotFoundError

An edge addressed by uuid or by its (from, to, type[, name]) key does not exist.

name is the edge name that narrowed the lookup, or None when the caller addressed by the bare triple (which, for a multigraph, may match several edges, see AmbiguousEdgeError).

exception energydb.errors.SeriesNotFoundError(message: str, *, route: str | None = None, missing: Sequence[tuple[str, ...]] | None = None)[source]

Bases: NotFoundError

One or more addressed series are not registered.

route names the manifest route the lookup went through: "path", "node_uuid", "edge_uuid", or "edge_triple".

missing carries every unresolved key, not just the one named in the message. Each entry is the route’s owner identity followed by (data_type, name): a 3-tuple for the single-column routes ((owner, data_type, name)), and a 5-tuple for "edge_triple", whose owner is itself the (from_path, to_path, edge_type) triple. Read the last two elements for the series, and the leading ones for the owner.

exception energydb.errors.AlreadyExistsError[source]

Bases: EnergyDBError, ValueError

Create-only violation: the entity, or a conflicting registration, already exists.

Raised by register_tree on pre-existing or duplicate UUIDs, and when a series is re-registered with different immutable attributes.

exception energydb.errors.ValidationError[source]

Bases: EnergyDBError, ValueError

Invalid arguments or an invalid operation.

Bad kwarg combinations, invalid enum/choice values, missing required fields, payload references that point outside the tree, move-into-own- subtree, dry_run inside a transaction(), and so on.

exception energydb.errors.AmbiguousEdgeError(message: str, *, from_path: str | None = None, to_path: str | None = None, edge_type: str | None = None, matches: Sequence[Mapping[str, Any]] | None = None)[source]

Bases: ValidationError

An edge triple matches more than one edge and no name narrowed it.

edge is a multigraph: (edge_type, from_node_uuid, to_node_uuid, name) is the unique key, so several parallel edges (the six circuits of a double-circuit corridor, say) can share one endpoint pair and type and are told apart by their name. Any triple-addressed lookup that lands on more than one of them is a genuinely ambiguous address, and energydb refuses to guess.

matches carries every candidate as {"uuid": UUID, "name": str | None} in a stable order, so an API server can render a “which circuit did you mean?” choice instead of parsing the message. The fix is in the message too: pass name= (fluent addressing) or add an edge_name column (manifest routing).

exception energydb.errors.ManifestError[source]

Bases: ValidationError

Structurally invalid manifest.

Missing or ambiguous routing columns, missing required columns, wrong dtypes, null routing values.

exception energydb.errors.UnchangedScopeError(message: str, *, overlapping_series_ids: Collection[int] | None = None)[source]

Bases: ValidationError

skip_unchanged was asked for with a comparison key that would lose data.

Raised when unchanged_scope="valid_time" is requested explicitly for a manifest containing OVERLAPPING series: that key ignores knowledge_time, so a genuine republication whose values happen to match the previous one would be dropped. overlapping_series_ids carries the offending series.

exception energydb.errors.ConfigurationError[source]

Bases: EnergyDBError, ValueError

Client or environment misconfiguration (unusable conninfo, …).

exception energydb.IncompatibleUnitError[source]

Bases: EnergyDBError, ValueError

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.

Data Model Re-Exports

For convenience, energydb re-exports the public EnergyDataModel and TimeDataModel API, so a portfolio can be declared without a second import. These classes are documented in their own projects; the names available as edb.* are:

Structure and base types

Element, Node, Edge, Reference, Asset, NodeAsset, GridNode, Sensor, Collection

Collections and portfolios

Portfolio, Site, MultiSite, Region, EnergyCommunity, VirtualPowerPlant

Geographic and market areas

Area, BiddingZone, ControlArea, Country, SynchronousArea, WeatherCell

Asset submodules

edb.wind, edb.solar, edb.battery, edb.hydro, edb.heatpump, edb.building, edb.grid, edb.weather — each holding the concrete asset classes for that domain (e.g. edb.wind.WindTurbine, edb.grid.Line)

Time-series declarations

TimeSeries, DataType, DataShape, Frequency, TimeSeriesType

Metric helpers

Kind, Quantity, Scope, build_metric, and the prebuilt metrics cross_border_flow, electricity_demand, electricity_demand_area, electricity_supply, electricity_supply_area, gas_demand, gas_supply, grid_frequency, heating_demand, spot_price, temperature

Schema (SQLAlchemy Models)

All tables live in the schema named by ENERGYDB_SCHEMA, defaulting to public. The SQLAlchemy models are the single source of truth — no raw SQL files. Platform code imports energydb.models.Base for Alembic migrations. Series immutability (retention, canonical_unit, owner columns) is enforced in Python by register_series rather than by a DB trigger, so the schema is fully Alembic-autogeneratable.

SQLAlchemy declarative models for EnergyDB PostgreSQL tables.

These models are the single schema source of truth and 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, with 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.