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:
objectSynchronous 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 returnedAsyncClientview, so the result is a sync, namespace-bound view sharing this client’s pool (and, like the async view, refuses lifecycle/schema operations). Always callclose()(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()
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:
objectAsync-native client for energy assets, hierarchy, and time series.
Owns the psycopg
AsyncConnectionPool(used for all PG ops) and constructs aTimeDBClientfor ClickHouse I/O. Every PG round-trip is awaited; the ClickHouse leg (syncclickhouse-connect) is offloaded to a worker thread. Synchronous callers useenergydb.Client, a thin blocking facade over this class.await client.open()before first use, andawait 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 bysetup_ch_meta_engine()); anything else, and any engine failure, uses the sequential path, with identical results. SetENERGYDB_DISABLE_ENGINE=1to 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 raisesValidationError, 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(theseries_metaview rides on the DDL events), created in a worker thread becausecreate_alland 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. frompostgresql:///db?host=/run/postgresql): the fast, engine-backed read path needs PostgreSQL reachable over TCP from ClickHouse, so setENERGYDB_CH_PG_HOSTif 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_COLLECTIONto 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
ConfigurationErroron PostgreSQL older than 15: theedge_uniqmultigraph key needsUNIQUE NULLS NOT DISTINCT.
- async 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, 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_typeis stored as a free-form string anddataverbatim, bypassing EnergyDataModel (de)serialization.parentselects the parent node (UUID or path);Nonecreates a root.uuidis minted (uuid7) when omitted. Read these nodes back withget_node_raw()/get_subtree_raw()orNodeScope.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
publicschema (SCHEMA is None), drops only EnergyDB’s own four tables, never the sharedpublicschema, 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
EdgeScopeby uuid or by(from_path, to_path, type[, name]).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.namepicks 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 raisesAmbiguousEdgeErrorlisting the candidates rather than picking one.
- 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.
- 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}orNoneif the node does not exist. Safe for anynode_typestring, unlikeget_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-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().
- 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.afteris a(name, uuid)keyset cursor matching theORDER BY name, uuid::textordering;limitcaps the page. Row shape matchesget_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_colis"node_uuid"(default) or"edge_uuid".series_idis the timedb-internal handle (the same valueNodeScope.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.namespaceGUC (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 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. 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.
withinaccepts a/-joined string ("P/Site"), a path tuple/list of segments, or aUUID. One round-trip either way: thewithinsubtree 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 (Utf8joined 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 nodepathis. 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 raisesAmbiguousEdgeError.
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_pathareUtf8joined with/;edge_nameis the edge’s own name, always present and null for unnamed edges. 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,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 byvalid_timeascending, thenknowledge_time/change_timewhen 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 withSeriesNotFoundError, naming every unresolved triple, and the return value is as described above. With"skip", those triples are dropped and the call returns aReadResultof(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 theoutput/backendcontract, and foron_missing, which switches the return type toReadResultwhen set to"skip", exactly as it does there.**td_kwargsare forwarded totimedb.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
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 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; useNodeScope.register_series()/EdgeScope.register_series().Returns the
uuidof the tree’s root, except whendry_run=True(which returns theTreeDiff).
- async setup_ch_meta_engine() None[source]
Provision the ClickHouse ↔ PG metadata bridge for
concurrentreads.Idempotent. (Re)creates the PG
series_metaview and the ClickHousePostgreSQL()engine table over it (seeenergydb._ch_meta_enginefor the credential/vantage resolution). Unlikecreate()’s best-effort provisioning this raises on failure, and it clears the session’s engine-unavailable degrade flag; call it to re-enableconcurrentafter fixing engine infrastructure.Set
ENERGYDB_CH_PG_COLLECTIONto a ClickHouse named collection for production deployments; without it the PostgreSQL password is inlined into the DDL and readable viaSHOW CREATE TABLE(warned about at provisioning time).Raises
ConfigurationErrorif the PG DSN has no TCP host as seen from ClickHouse (seecreate()); setENERGYDB_CH_PG_HOSTto fix it.
- 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.
- 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.
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.skip_unchangeddrops rows whose latest stored value is unchanged before the insert.unchanged_scopepicks the comparison key:"auto"(default): per series, by its registered type. FLAT compares pervalid_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. RaisesUnchangedScopeErrorif the manifest contains OVERLAPPING series, since it would drop their republications.
Series must already be registered (typically via
register_tree()). Returns aWriteResult, anintrun_id carryingwritten/skippedcounts.
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:
intThe
run_id(anint) carrying row counts from a write.Subclasses
intso existing callers that treat the return value as a run_id keep working unchanged;written/skippedride along as attributes, and.run_idreads as the int value.
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:
NamedTupleA read’s data plus the manifest triples that resolved to no series.
Returned by
read()/read_relative()only whenon_missing="skip"; the default ("raise") returnsdatabare, so existing callers never see this type.datais exactly what the same call would return withouton_missing(honouringoutputandbackend, including the empty shapes).missingholds the unique unresolvable triples: the manifest’s routing column(s) plusdata_type/name,Utf8throughout (uuids stringified), zero-row with the right schema when everything resolved. It followsbackendlikedatadoes.A
NamedTuple, sodata, missing = await client.read(...)unpacks, mirroringWriteResult’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:
NamedTupleTyped 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).
- class energydb.EdgeSeriesKey(from_path: str, to_path: str, edge_type: str, edge_name: str | None, data_type: str, name: str)[source]
Bases:
NamedTupleTyped 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(Nonefor an unnamed edge), and the series’s own(data_type, name)pair.edge_namesits fourth, next toedge_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.
- energydb.find(result: dict, **filters)[source]
Partial-match filter over a by_path result dict.
filtersare attribute-name → value pairs matched againstSeriesKey/EdgeSeriesKeyfields. 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:
_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.
- 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 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.
- 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. Withdry_run=Truenothing is written and aTreeDiffis 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
Elementkeeps the stored UUID, so it round-trips. Series are not attached: useClient.get_tree(include_series=True)for that, orget_raw()for the plain row.Raises
NodeNotFoundErrorif 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, 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
- 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}orNoneif the uuid-addressed node does not exist (a path-addressed miss raises, matching the resolve contract). Use for generic node types (anynode_typestring), whereget()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
metawithin the window — one ClickHouse aggregate. Returns up tolimit + 1so 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.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.
- 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
NodeScopethe manifest spans the resolved subtree; forEdgeScopeit’s the single edge. SeeClient.read()for theoutput/backendcontract. 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
metaframe fromresolve(): the ClickHouse leg only, with no further PG round-trip.output/backendfollow theread()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_kwargsare forwarded totimedb.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. Whenretentionis omitted it is derived fromtimeseries_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
pathand every descendant’spathare rewritten in the same statement. Withdry_run=Truenothing is written and aTreeDiffof 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 withseries_id,canonical_unit,timeseries_type,data_type,nameand (for node scopes) the materializedpath, orNoneif nothing matches. Splitting resolve from the read lets a caller authorize or inspect (e.g. bypath) before paying for the ClickHouse read;resolve()thenread_from_meta()is exactly whatread()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
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().
- async valid_range_from_meta(meta: DataFrame) tuple[datetime, datetime] | None
Overall
[min, max]valid_time across the series inmeta(a frame fromresolve()) — one ClickHouse aggregate, no row reads.Nonewhen 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 optionalunit) overdf(pandas or polars) and delegates toClient.write().skip_unchanged/unchanged_scopeare forwarded; the default"auto"picks the comparison key from this series’ registered type, so an OVERLAPPING series keeps its republications; seeClient.write(). Returns aWriteResult, anintrun_id carryingwritten/skippedcounts.
- 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:
_BaseScopeScope for operating on a single edge.
Identified by
uuidor by the(from_path, to_path, edge_type)triple, optionally narrowed byedge_name, which is what tells parallel edges of a multigraph apart. A triple matching several edges raisesAmbiguousEdgeErroron resolution.- 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=Truenothing is written and aTreeDiffis returned.
- async get()[source]
Reconstruct this edge as an EnergyDataModel object, endpoints included.
Raises
EdgeNotFoundErrorwhen 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}orNoneif 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, mirroringNodeScope.get_raw(): no EDM reconstruction, so it works for anyedge_typestring whereget()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
metawithin the window — one ClickHouse aggregate. Returns up tolimit + 1so 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 raisesAlreadyExistsError; give the edge a distinctname(seerename()) 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
NodeScopethe manifest spans the resolved subtree; forEdgeScopeit’s the single edge. SeeClient.read()for theoutput/backendcontract. 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
metaframe fromresolve(): the ClickHouse leg only, with no further PG round-trip.output/backendfollow theread()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_kwargsare forwarded totimedb.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. Whenretentionis omitted it is derived fromtimeseries_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 raisesAlreadyExistsError.With
dry_run=Truenothing is written and aTreeDiffof 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 withseries_id,canonical_unit,timeseries_type,data_type,nameand (for node scopes) the materializedpath, orNoneif nothing matches. Splitting resolve from the read lets a caller authorize or inspect (e.g. bypath) before paying for the ClickHouse read;resolve()thenread_from_meta()is exactly whatread()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 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().
- async valid_range_from_meta(meta: DataFrame) tuple[datetime, datetime] | None
Overall
[min, max]valid_time across the series inmeta(a frame fromresolve()) — one ClickHouse aggregate, no row reads.Nonewhen 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 optionalunit) overdf(pandas or polars) and delegates toClient.write().skip_unchanged/unchanged_scopeare forwarded; the default"auto"picks the comparison key from this series’ registered type, so an OVERLAPPING series keeps its republications; seeClient.write(). Returns aWriteResult, anintrun_id carryingwritten/skippedcounts.
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:
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.- 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
EdgeScopebound to this transaction.Same addressing as
Client.get_edge(name=included, for parallel edges), with the same transaction semantics asget_node().
- get_node(*names_or_path, uuid: UUID | None = None) NodeScope[source]
Return a
NodeScopebound to this transaction.Same addressing as
Client.get_node, but every mutation runs on the transaction’s connection and stays uncommitted untilcommit(). Time-seriesread/write/read_relativeon the returned scope raiseRuntimeError; they do not participate in the PostgreSQL transaction.
- 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.
- 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 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>)
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 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
datachanged.
- 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.
- class energydb.EdgeChange(old: SnapshotT | None, new: SnapshotT | None)
Bases:
_BaseChange[EdgeSnapshot]A single edge-level diff entry.
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:
ExceptionBase class for every exception energydb raises deliberately.
- exception energydb.errors.NotFoundError[source]
Bases:
EnergyDBError,ValueErrorAn addressed entity does not exist.
- exception energydb.errors.NodeNotFoundError(message: str, *, path: str | None = None, uuid: UUID | None = None)[source]
Bases:
NotFoundErrorA node addressed by path or by uuid does not exist.
pathis the/-joined path that was addressed;uuidthe addressed node uuid. Either may beNone: 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:
NotFoundErrorAn edge addressed by uuid or by its
(from, to, type[, name])key does not exist.nameis the edge name that narrowed the lookup, orNonewhen the caller addressed by the bare triple (which, for a multigraph, may match several edges, seeAmbiguousEdgeError).
- exception energydb.errors.SeriesNotFoundError(message: str, *, route: str | None = None, missing: Sequence[tuple[str, ...]] | None = None)[source]
Bases:
NotFoundErrorOne or more addressed series are not registered.
routenames the manifest route the lookup went through:"path","node_uuid","edge_uuid", or"edge_triple".missingcarries 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,ValueErrorCreate-only violation: the entity, or a conflicting registration, already exists.
Raised by
register_treeon pre-existing or duplicate UUIDs, and when a series is re-registered with different immutable attributes.
- exception energydb.errors.ValidationError[source]
Bases:
EnergyDBError,ValueErrorInvalid 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_runinside atransaction(), 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:
ValidationErrorAn edge triple matches more than one edge and no
namenarrowed it.edgeis 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 theirname. Any triple-addressed lookup that lands on more than one of them is a genuinely ambiguous address, and energydb refuses to guess.matchescarries 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: passname=(fluent addressing) or add anedge_namecolumn (manifest routing).
- exception energydb.errors.ManifestError[source]
Bases:
ValidationErrorStructurally 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:
ValidationErrorskip_unchangedwas 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 ignoresknowledge_time, so a genuine republication whose values happen to match the previous one would be dropped.overlapping_series_idscarries the offending series.
- exception energydb.errors.ConfigurationError[source]
Bases:
EnergyDBError,ValueErrorClient or environment misconfiguration (unusable conninfo, …).
- exception energydb.IncompatibleUnitError[source]
Bases:
EnergyDBError,ValueErrorRaised 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 metricscross_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.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 in Python byregister_series).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.