EnergyDB — Quickstart

Declare your energy portfolio — sites, turbines, arrays, batteries — as plain Python objects. One call persists the whole tree; one fluent line reads it back across every asset.

1. Setup

Client reads connection settings from TIMEDB_PG_DSN (Postgres for hierarchy + series catalog) and TIMEDB_CH_URL (ClickHouse for time-series values).

[1]:
try:
    import urllib.request

    import google.colab  # noqa: F401

    urllib.request.urlretrieve(
        "https://raw.githubusercontent.com/rebase-energy/energydb/main/examples/colab_setup.py", "/tmp/colab_setup.py"
    )
    exec(open("/tmp/colab_setup.py").read())
except ImportError:
    pass
[2]:
from datetime import UTC, datetime

import energydb as edb
import pandas as pd

client = edb.Client()
client.delete()  # clean slate
client.create()

2. Declare your portfolio

A Portfolio is just a Python object — sites, turbines, arrays, batteries — with metadata-only TimeSeries declarations naming the series each node will hold. Build it top-down; the tree printout below is your structural deliverable.

[3]:
# Step 1 — declare the series this turbine will hold (metadata only; data lands later via write).
power_actual = edb.TimeSeries(name="power", unit="MW", data_type=edb.DataType.ACTUAL)
wind_speed = edb.TimeSeries(name="wind_speed", unit="m/s", data_type=edb.DataType.ACTUAL)
power_forecast = edb.TimeSeries(
    name="power",
    unit="MW",
    data_type=edb.DataType.FORECAST,
    timeseries_type=edb.TimeSeriesType.OVERLAPPING,
)

# Step 2 — wrap them on a WindTurbine (and a sibling with a single declaration inline).
t01 = edb.wind.WindTurbine(
    name="T01",
    lat=55.01,
    lon=3.02,
    capacity=3.5,
    hub_height=80,
    timeseries=[power_actual, wind_speed, power_forecast],
)
t02 = edb.wind.WindTurbine(
    name="T02",
    lat=55.01,
    lon=3.04,
    capacity=3.5,
    hub_height=80,
    timeseries=[edb.TimeSeries(name="power", unit="MW", data_type=edb.DataType.ACTUAL)],
)

# Step 3 — group the turbines under a Site that carries its own series too.
offshore_1 = edb.Site(
    name="Offshore-1",
    lat=55.0,
    lon=3.0,
    timeseries=[edb.TimeSeries(name="demand", unit="MW", data_type=edb.DataType.ACTUAL)],
    members=[t01, t02],
)

# Step 4 — same bottom-up pattern for the second site: array → system, plus a battery, then site.
pv_array = edb.solar.PVArray(
    name="Array-1",
    capacity=10,
    surface_tilt=25,
    surface_azimuth=180,
    timeseries=[edb.TimeSeries(name="power", unit="MW", data_type=edb.DataType.ACTUAL)],
)
pv_system = edb.solar.PVSystem(name="PV01", members=[pv_array])
battery = edb.battery.Battery(name="B01", storage_capacity=1000, max_charge=500)
rooftop_1 = edb.Site(name="Rooftop-1", lat=52.0, lon=4.5, members=[pv_system, battery])

# Step 5 — assemble the portfolio.
portfolio = edb.Portfolio(name="my-portfolio", members=[offshore_1, rooftop_1])
print(portfolio.to_tree())
Portfolio('my-portfolio')
├── Site('Offshore-1')
│   ├── WindTurbine('T01')
│   └── WindTurbine('T02')
└── Site('Rooftop-1')
    ├── PVSystem('PV01')
    │   └── PVArray('Array-1')
    └── Battery('B01')

3. Persist and write data

register_tree creates every node, edge, and series declaration in one PG transaction — the UUIDs you set in Python become the row primary keys. Then write streams in the actual values, one short call per series.

[4]:
client.register_tree(portfolio)

# A day of synthetic hourly values, varied by base level per series.
start = datetime(2026, 1, 1, tzinfo=UTC)
end = datetime(2026, 1, 2, tzinfo=UTC)
hours = pd.date_range(start, end, freq="1h", inclusive="left", tz="UTC")


def synthetic_dataframe(base):
    return pd.DataFrame({"valid_time": hours, "value": [base + 0.05 * i for i in range(len(hours))]})


offshore = client.get_node("my-portfolio", "Offshore-1")
offshore.write(synthetic_dataframe(12.0), name="demand", data_type="actual")
offshore.get_node("T01").write(synthetic_dataframe(2.5), name="power", data_type="actual")
offshore.get_node("T01").write(synthetic_dataframe(7.5), name="wind_speed", data_type="actual")
offshore.get_node("T02").write(synthetic_dataframe(2.7), name="power", data_type="actual")

rooftop = client.get_node("my-portfolio", "Rooftop-1", "PV01", "Array-1")
rooftop.write(synthetic_dataframe(5.0), name="power", data_type="actual")
[4]:
58297317239439162

4. Read across the portfolio

The pay-off. One fluent scope, many routings — full subtree, single-asset drill-down, series-name filter, node-type filter. Each line below is one indexed query.

[5]:
portfolio = client.get_node("my-portfolio")
portfolio.read(data_type="actual", start_valid=start)  # everything — every series on every node
df = portfolio.read(data_type="actual", name="power", start_valid=start)  # only 'power' values
portfolio.where(type="WindTurbine").read(data_type="actual", name="power")  # turbines only
portfolio.get_node("Offshore-1", "T01").read(data_type="actual", name="wind_speed")  # one asset, one series
df
[5]:
shape: (72, 5)
valid_timevaluepathdata_typename
datetime[μs, UTC]f64strstrstr
2026-01-01 00:00:00 UTC2.5"my-portfolio/Offshore-1/T01""actual""power"
2026-01-01 01:00:00 UTC2.55"my-portfolio/Offshore-1/T01""actual""power"
2026-01-01 02:00:00 UTC2.6"my-portfolio/Offshore-1/T01""actual""power"
2026-01-01 03:00:00 UTC2.65"my-portfolio/Offshore-1/T01""actual""power"
2026-01-01 04:00:00 UTC2.7"my-portfolio/Offshore-1/T01""actual""power"
2026-01-01 19:00:00 UTC5.95"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 20:00:00 UTC6.0"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 21:00:00 UTC6.05"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 22:00:00 UTC6.1"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 23:00:00 UTC6.15"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"

5. Reconstruct the tree

Pull the whole portfolio back as an EDM tree — same UUIDs, ready for inspection or in-memory edits.

[6]:
tree = client.get_tree("my-portfolio", include_series=True)
print(tree)
Portfolio('my-portfolio')
├── Site('Offshore-1')
│   ├── WindTurbine('T01')
│   └── WindTurbine('T02')
└── Site('Rooftop-1')
    ├── PVSystem('PV01')
    │   └── PVArray('Array-1')
    └── Battery('B01')

6. Modify existing nodes and edges

register_tree is create-only — to edit what’s already persisted, use scope mutators (rename, update, delete, move_to, add) addressed by path or uuid. The node keeps its uuid, so attached series stay attached. Every mutator accepts dry_run=True to return a TreeDiff preview without committing. client.transaction() batches several mutations into one atomic commit; .commit() is required, exiting without it raises.

[7]:
client.get_node("my-portfolio", "Offshore-1").rename("Offshore-Renamed")
client.get_node("my-portfolio", "Offshore-Renamed", "T01").update({"capacity": 4.0})
client.get_node("my-portfolio", "Offshore-Renamed", "T02").delete()

# Add a new turbine under Offshore-Renamed; .add() returns a scope at the new node.
client.get_node("my-portfolio", "Offshore-Renamed").add(edb.wind.WindTurbine(name="T03", capacity=4.5))

renamed = client.get_node("my-portfolio", "Offshore-Renamed").get()
print(f"Renamed: {renamed.name} (uuid={renamed.id})")
Renamed: Offshore-Renamed (uuid=019e3a33-c9cd-7bfa-a262-1ba2f3cafbd5)
[8]:
diff = client.get_node("my-portfolio", "Offshore-Renamed", "T01").update({"hub_height": 95}, dry_run=True)
diff.render()

# DB is unchanged after a dry_run.
print("hub_height still:", client.get_node("my-portfolio", "Offshore-Renamed", "T01").get().hub_height)
~ WindTurbine 'T01'  [hub_height: 80 → 95]
hub_height still: 80
[9]:
with client.transaction() as txn:
    txn.get_node("my-portfolio", "Offshore-Renamed", "T01").update({"hub_height": 95})
    txn.get_node("my-portfolio", "Offshore-Renamed", "T03").rename("T02")
    txn.get_node("my-portfolio", "Rooftop-1", "B01").move_to(("my-portfolio", "Offshore-Renamed"))
    txn.preview().render()
    txn.commit()
~ WindTurbine 'T01'  [hub_height: 80 → 95]
~ WindTurbine 'T02'  [rename 'T03' → 'T02']
~ Battery 'B01'  [moved (parent 019e3a33-c9d1-7c29-ad0e-cc9d87e6152e → 019e3a33-c9cd-7bfa-a262-1ba2f3cafbd5)]

7. Bulk manifest I/O

A single manifest DataFrame fans out a read across many series at once — routing column is path, node_uuid, or edge_uuid, detected automatically.

[10]:
manifest = pd.DataFrame(
    [
        {"path": "my-portfolio/Offshore-Renamed/T01", "data_type": "actual", "name": "power"},
        {"path": "my-portfolio/Rooftop-1/PV01/Array-1", "data_type": "actual", "name": "power"},
    ]
)

df = client.read(manifest, start_valid=start)
df
[10]:
shape: (48, 5)
valid_timevaluepathdata_typename
datetime[μs, UTC]f64strstrstr
2026-01-01 00:00:00 UTC2.5"my-portfolio/Offshore-Renamed/…"actual""power"
2026-01-01 01:00:00 UTC2.55"my-portfolio/Offshore-Renamed/…"actual""power"
2026-01-01 02:00:00 UTC2.6"my-portfolio/Offshore-Renamed/…"actual""power"
2026-01-01 03:00:00 UTC2.65"my-portfolio/Offshore-Renamed/…"actual""power"
2026-01-01 04:00:00 UTC2.7"my-portfolio/Offshore-Renamed/…"actual""power"
2026-01-01 19:00:00 UTC5.95"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 20:00:00 UTC6.0"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 21:00:00 UTC6.05"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 22:00:00 UTC6.1"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"
2026-01-01 23:00:00 UTC6.15"my-portfolio/Rooftop-1/PV01/Ar…"actual""power"

8. Edges

Lines, links, and pipes link two nodes. Pass the endpoint nodes directly — the edge captures their .id. Edges hold their own time series the same way nodes do (register_serieswriteread).

[11]:
bus_a = edb.grid.JunctionPoint(name="BusA")
bus_b = edb.grid.JunctionPoint(name="BusB")
line = edb.grid.Line(name="Cable-1", capacity=500, from_element=bus_a, to_element=bus_b)

offshore = client.get_node("my-portfolio", "Offshore-Renamed")
offshore.add(bus_a)
offshore.add(bus_b)
client.create_edge(line)

found = client.get_edge(
    from_path=("my-portfolio", "Offshore-Renamed", "BusA"),
    to_path=("my-portfolio", "Offshore-Renamed", "BusB"),
    type="Line",
).get()
print(f"{type(found).__name__}: {found.name} (uuid={found.id})")
Line: Cable-1 (uuid=019e3a33-cbe2-7189-8e27-5df23845b82f)

9. Cleanup

[12]:
client.delete()