Build a dashboard
Read the schema, dry-run a document, create it, add a component with PUT, place zeros across panes, and delete it.
This is the sequence an author or an agent follows. It assumes the BASE and HEADERS setup from Authentication, and a workspace whose sites carry a power series (measured production) and a power-forecast series with a power column, as the Quickstart and Upload / read timeseries set up.
Read the schema
schema = requests.get(f"{BASE}/dashboards/schema", headers=HEADERS).json()
print(sorted(schema["$defs"]))
# ['AxisSpec', 'ChartComponent', 'ChartSeries', 'ChartSpec', 'ForEach', 'GroupComponent', 'Layout', 'PaneSpec', 'Window']Write a document and dry-run it
One chart per site, forecast against actual, each axis pinned to that site's capacity:
doc = {
"name": "Power",
"description": "Forecast against actual production, one chart per site.",
"window": {"last": "P2D", "ahead": "P2D"},
"components": [{
"type": "group",
"title": "Sites",
"for_each": {"object": "sites", "limit": 50},
"component": {
"type": "chart",
"title": "{{ item.name }}",
"width": 6, "height": 1,
"chart": {
"axes": {"left": {"min": 0, "max": "{{ item.stats.total_capacity_kw }} / 1000"}},
"now_line": True,
"series": [
{"timeseries": "power", "label": "Actual", "color": "#f5b544"},
{"timeseries": "power-forecast", "column": "power", "label": "Forecast", "color": "#34d399"},
],
},
},
}],
}
r = requests.post(f"{BASE}/dashboards/resolve", headers=HEADERS, json=doc)
r.raise_for_status()
res = r.json()
print(res["warnings"]) # []
cards = res["components"][0]["components"]
print(len(cards)) # 10
print([(c["title"], c["chart"]["axes"][0]["max"]) for c in cards][:3])
# [('Hitra', 55.2), ('Smola', 156.4), ('Utsira', 1.2)]
print([s["error"] for c in cards for s in c["chart"]["series"] if s["error"]])
# [] (a site without a `power` series would show "no timeseries 'power' on this record")A 422 here means the document itself is wrong. Typical messages:
component 0: series 0: name a 'record', or put the chart in a 'group'
component 0: '{{ item.* }}' needs a 'group' to iterate over
component 0: unknown object type(s) nope
axes 'power' and 'wind' both sit left on pane 0
'panes' declares 1 pane(s) but an axis sits on pane 1
axis 'wind': 'align_zero' must name an axis on the same pane
'zero_at' / 'align_zero' derive 'min' from 'max' and the data; drop 'min'
a bound is a number, or a template such as "{{ item.stats.total_capacity_kw }} / 1000"Create it
r = requests.post(f"{BASE}/dashboards", headers=HEADERS, json=doc)
r.raise_for_status() # 201
dashboard = r.json()
print(dashboard["id"], dashboard["component_count"]) # c1719097-... 1
print(dashboard["components"][0]["component"]["height"]) # 1 (defaults filled in)A second POST with the same name is a 409.
Resolve and read
res = requests.get(f"{BASE}/dashboards/{dashboard['id']}/resolve", headers=HEADERS).json()Then read the lines with one batch read, as in Resolving and rendering.
Add a component with PUT
A full-width stacked chart of a price zone's production by type above the group. PUT takes the whole document, so start from what GET returned:
doc = requests.get(f"{BASE}/dashboards/{dashboard['id']}", headers=HEADERS).json()
body = {k: doc[k] for k in ("name", "description", "layout", "window", "components")}
body["components"].insert(0, {
"type": "chart",
"title": "SE3 production",
"width": 12, "height": 2,
"chart": {
"kind": "area", "stacked": True, "now_line": True,
"axes": {"left": {"min": 0}},
"series": [
{"timeseries": "production", "column": "nuclear", "record": SE3_ID, "label": "Nuclear", "color": "#a78bfa"},
{"timeseries": "production", "column": "hydro", "record": SE3_ID, "label": "Hydro", "color": "#3b82f6"},
{"timeseries": "production", "column": "thermal", "record": SE3_ID, "label": "Thermal", "color": "#f87171"},
{"timeseries": "production", "column": "wind", "record": SE3_ID, "label": "Wind", "color": "#34d399"},
{"timeseries": "production", "column": "solar", "record": SE3_ID, "label": "Solar", "color": "#f5b544"},
],
},
})
r = requests.put(f"{BASE}/dashboards/{dashboard['id']}", headers=HEADERS, json=body)
r.raise_for_status()
print(r.json()["component_count"]) # 2SE3_ID is the price zone's record id. Listing the five columns explicitly, rather than one series over the whole type, lets each type carry its own label and colour.
Two panes, four axes, placed zeros
An icing dashboard: a power forecast with icing losses as negative bars on the top pane's left axis, wind speed on its right axis sharing the zero line, temperature and precipitation on a second pane.
doc = {
"name": "Site iceloss",
"window": {"last": "P2D", "ahead": "P2D"},
"components": [{
"type": "group", "title": "Per site",
"for_each": {"object": "sites", "limit": 12},
"component": {
"type": "chart", "title": "{{ item.name }}", "width": 6, "height": 2,
"chart": {
"axes": {
"power": {"pane": 0, "side": "left", "zero_at": 0.3,
"max": "{{ item.stats.total_capacity_kw }} / 1000"},
"wind": {"pane": 0, "side": "right", "align_zero": "power"},
"temp": {"pane": 1, "side": "left", "label": "°C", "min": -20, "max": 20},
"rain": {"pane": 1, "side": "right", "min": 0, "max": 10},
},
"panes": [{"height": 2}, {"height": 1}],
"now_line": True,
"series": [
{"timeseries": "power", "axis": "power", "label": "Actual", "color": "#f5b544"},
{"timeseries": "power-forecast", "column": "power", "axis": "power", "label": "Forecast", "color": "#34d399"},
{"timeseries": "iceloss", "axis": "power", "kind": "bar", "scale": -1, "label": "Iceloss", "color": "#93c5fd"},
{"timeseries": "wind-speed-100m", "axis": "wind", "dash": "dotted", "label": "Wind speed 100m", "color": "#a78bfa"},
{"timeseries": "temperature-2m", "axis": "temp", "dash": "dotted", "label": "Temperature 2m", "color": "#f87171"},
{"timeseries": "precipitation", "axis": "rain", "kind": "bar", "label": "Precipitation", "color": "#22d3ee"},
],
},
},
}],
}Three things are doing the work here:
zero_at: 0.3on thepoweraxis keeps the negative icing bars in the bottom 30% of the pane and lets the forecast use the rest. The top is pinned to the site's capacity by the templatedmax; the bottom follows the data.align_zero: "power"on thewindaxis makes wind speed share the power axis's zero line, so the two read against each other without a fixed bound.scale: -1on theicelossseries draws losses as negative bars with no change to the stored data.
Resolved, each card lists its axes in drawing order with labels filled from units:
res = requests.post(f"{BASE}/dashboards/resolve", headers=HEADERS, json=doc).json()
cards = res["components"][0]["components"]
print([(a["key"], a["pane"], a["side"], a["label"], a["max"]) for a in cards[0]["chart"]["axes"]])
# [('power', 0, 'left', 'MW', 55.2), ('wind', 0, 'right', 'm/s', None), ('temp', 1, 'left', '°C', 20.0), ('rain', 1, 'right', 'mm', 10.0)]Delete
r = requests.delete(f"{BASE}/dashboards/{dashboard['id']}", headers=HEADERS)
print(r.status_code) # 204Only the dashboard goes; records and timeseries are untouched.
The loop, in short
Fetch the schema; write the document; POST /dashboards/resolve; read warnings, every card's errors, and every line's error; fix; POST /dashboards. Prefer a group over listing record ids, so the dashboard follows the data. Give every group a title and every chart one; {{ item.name }} is the usual chart title inside a group. To change a dashboard, GET it, edit, PUT it back.