rebase.energy

Build a wind fleet end to end

Create a fleet of sites, load a year of hourly production in one call, read it all back chart-ready, and check its coverage.

This tutorial exercises the whole API in one realistic flow: authenticate, create a fleet of sites with their assets, upload a year of hourly production for all of them in one call, read every site back in one call and sum a fleet total, then check the data's coverage and current values.

It is modelled on the open NVE dataset of Norwegian wind farms, which has 62 sites and a year of hourly production for each. To keep the code self-contained, this page uses three sites and generates a stand-in production series in the same shape. Swap in your own data where the comments say so. It assumes the BASE and HEADERS setup from Authentication.

1. Confirm access

r = requests.get(f"{BASE}/objects", headers=HEADERS)
r.raise_for_status()
assert "sites" in [t["name"] for t in r.json()["items"]]

2. Create the fleet

One POST /objects/sites per park, each with its turbines inline. A site and its assets are created atomically, so a failure leaves nothing half-made.

FLEET = [
    # name, latitude, longitude, turbine model, count, kW per turbine
    ("Smola",   63.40,  8.00, "Siemens 2.3", 68, 2300),
    ("Hitra",   63.55,  8.78, "Siemens 2.3", 24, 2300),
    ("Utsira",  59.31,  4.89, "Enercon E-40", 2,  600),
]

sites = []
for name, lat, lon, model, count, kw in FLEET:
    r = requests.post(f"{BASE}/objects/sites", headers=HEADERS, json={
        "name": name,
        "values": {
            "latitude": lat,
            "longitude": lon,
            "assets": [
                {"name": f"{model} x{count}", "values": {"type": "wind_turbine", "capacity_kw": count * kw}},
            ],
        },
    })
    r.raise_for_status()
    sites.append(r.json())

fleet_kw = sum(s["stats"]["total_capacity_kw"] for s in sites)
print(f"{len(sites)} sites, {fleet_kw / 1000:.1f} MW")
3 sites, 212.8 MW

Each site record carries the generated id that every later call keys on.

3. Upload a year of hourly production in one call

Production is a singleindex series in MW. The helper below generates a plausible hourly series for the year from a site's capacity; replace production_for with a function that reads your own data.

Rather than one upload per site, POST /timeseries/write takes every site's series in one batch and lands them whole or not at all. Three sites times 8784 hours is 26,352 values, well inside the 1,000,000-value budget.

import math
import random
from datetime import datetime, timedelta, timezone

HOURS = 24 * 366  # 2024 is a leap year
T0 = datetime(2024, 1, 1, tzinfo=timezone.utc)


def production_for(capacity_mw: float) -> list[dict]:
    """Stand-in for real data: seasonal + daily cycle with noise, in MW."""
    random.seed(capacity_mw)
    points = []
    for h in range(HOURS):
        season = 0.35 + 0.15 * math.cos(2 * math.pi * h / HOURS)  # windier in winter
        daily = 0.05 * math.sin(2 * math.pi * (h % 24) / 24)
        cf = max(0.0, min(1.0, season + daily + random.gauss(0, 0.12)))
        points.append({
            "valid_time": (T0 + timedelta(hours=h)).isoformat(),
            "value": round(capacity_mw * cf, 3),
        })
    return points


r = requests.post(f"{BASE}/timeseries/write", headers=HEADERS, json={
    "series": [
        {
            "record": site["id"],
            "type": "power-production",
            "shape": "singleindex",
            "unit": "MW",
            "points": production_for(site["stats"]["total_capacity_kw"] / 1000),
        }
        for site in sites
    ],
})
r.raise_for_status()
result = r.json()
print(result["points_written"], "written,", result["points_skipped"], "skipped")
26352 written, 0 skipped

Run the cell again and it reports 0 written, 26352 skipped: the store already holds those values, so a retry after a network error needs no cleanup first.

4. Read the whole fleet back, chart-ready

A year of hourly data is 8784 points per site. One POST /timeseries/read fetches all three with six-hour bucket means, decided once for the whole batch, in columnar form.

r = requests.post(f"{BASE}/timeseries/read", headers=HEADERS, json={
    "series": [{"record": site["id"], "type": "power-production"} for site in sites],
    "start": "2024-01-01T00:00:00Z",
    "end": "2024-12-31T23:00:00Z",
    "bucket_seconds": 6 * 3600,
    "format": "columns",
})
r.raise_for_status()
items = r.json()["items"]
print(len(items), "series,", len(items[0]["times"]), "buckets each")
3 series, 1464 buckets each

Every item shares the same bucket grid, so the fleet total is a column-wise sum:

times = items[0]["times"]
fleet = [sum(item["values"]["value"][i] for item in items) for i in range(len(times))]

Plotting is a few lines away:

import matplotlib.pyplot as plt

plt.plot(times, fleet)
plt.ylabel("Fleet production (MW)")
plt.show()

For a raw export, drop bucket_seconds and format and page through the year with explicit start/end windows instead.

5. Check coverage and current values

Two more batch routes answer the questions a monitoring job asks. POST /timeseries/stats with daily buckets says how many days of the year hold data for each site:

r = requests.post(f"{BASE}/timeseries/stats", headers=HEADERS, json={
    "series": [{"record": site["id"], "type": "power-production"} for site in sites],
    "start": "2024-01-01T00:00:00Z",
    "end": "2024-12-31T23:00:00Z",
    "bucket_seconds": 86400,
})
r.raise_for_status()
for item, site in zip(r.json()["items"], sites):
    stats = item["columns"]["value"]
    print(f"{site['name']:<8} {len(stats['buckets'])} of 366 days, last point {stats['extent']['last']}")

POST /timeseries/latest gives the most recent value per site without reading a window:

r = requests.post(f"{BASE}/timeseries/latest", headers=HEADERS, json={
    "series": [{"record": site["id"], "type": "power-production"} for site in sites],
})
r.raise_for_status()
for item, site in zip(r.json()["items"], sites):
    point = item["latest"]["value"]
    print(f"{site['name']:<8} {point['value']} MW at {point['valid_time']}")

6. Clean up (optional)

Deleting a site deletes its assets and every series you uploaded to it:

for site in sites:
    requests.delete(f"{BASE}/objects/sites/{site['id']}", headers=HEADERS).raise_for_status()

What you used

  • Sites: atomic create with inline assets, stats.total_capacity_kw.
  • Many series at once: batch write with dedup, batch read with shared bucketing and format=columns, stats, latest.
  • Upload / read timeseries: the singleindex shape and what a re-upload does.

On this page