Skip to main content

Accessing data

In this tutorial you obtain an access token, send your first GraphQL query and then read runtime entities, follow associations and query time series: raw values, sums per day and month, and pre-aggregated rollups. All examples are read-only.

The examples use the energy-community model from tutorial 3, because it has master data and time series. The query patterns are the same for every Construction Kit.

Time: about 60 minutes.

The endpoint​

Every tenant has one GraphQL endpoint on the asset repository:

https://assets.<your-domain>/tenants/<tenant>/GraphQL

Send queries as POST with a JSON body {"query": "...", "variables": {...}} and the header Authorization: Bearer <access-token>. Use the path exactly as shown, with /tenants/ in front of the tenant id; the spelling GraphQL is the canonical one used throughout this documentation.

For interactive exploration the same service hosts a playground with schema browser and auto-completion at https://assets.<your-domain>/tenants/<tenant>/graphql/playground.

Step 1: Get an access token​

Quick start: reuse your octo-cli login​

If you followed tutorial 1, octo-cli already holds a token for your context. For experiments you can read it from the context file:

octo-cli --context <context-name> -c AuthStatus > /dev/null # refreshes the token if needed
export OCTO_TOKEN=$(jq -r '.Contexts["<context-name>"].Authentication.AccessToken' ~/.octo-cli/contexts.json)
export OCTO_GRAPHQL=https://assets.<your-domain>/tenants/<tenant>/GraphQL

The token is valid for about an hour. It carries your user's roles, so a script using it can do exactly what you can do in the Studio.

Your own application: Authorization Code with PKCE​

A web or desktop application that acts on behalf of a user uses the OpenID Connect Authorization Code flow with PKCE. Your administrator registers an OAuth client for it in the tenant (see Registering a new web application) and grants it the scope octo_api (or octo_api.read_only for an application that only reads).

The flow:

  1. Your app creates a random code_verifier and its SHA-256 hash, the code_challenge.
  2. It sends the browser to the authorize endpoint of the identity service, with acr_values=tenant:<tenant> so that the login page of the right tenant opens.
  3. The user logs in, the browser returns to your redirect_uri with a code.
  4. Your app exchanges code plus code_verifier for tokens at the token endpoint.

The identity service publishes all endpoints in its discovery document at https://connect.<your-domain>/.well-known/openid-configuration. A minimal Python implementation for a local tool, using only the standard library:

pkce_login.py
import base64, hashlib, http.server, json, os, secrets, urllib.parse, urllib.request, webbrowser

ISSUER = "https://connect.<your-domain>"
CLIENT_ID = "<your-client-id>" # registered with redirect URI http://localhost:8765/callback
TENANT = "<tenant>"
REDIRECT_URI = "http://localhost:8765/callback"

verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)

params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile email role octo_api offline_access",
"acr_values": f"tenant:{TENANT}",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": state,
}
webbrowser.open(f"{ISSUER}/connect/authorize?{urllib.parse.urlencode(params)}")

class Callback(http.server.BaseHTTPRequestHandler):
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
assert query["state"][0] == state, "state mismatch"
self.server.code = query["code"][0]
self.send_response(200); self.end_headers()
self.wfile.write(b"Login complete - you can close this window.")

server = http.server.HTTPServer(("localhost", 8765), Callback)
server.handle_request()

token_request = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": server.code,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}).encode()
tokens = json.load(urllib.request.urlopen(f"{ISSUER}/connect/token", data=token_request))
access_token = tokens["access_token"] # keep it in memory, never log it

For browser applications use an established OIDC library (for example angular-auth-oidc-client or oidc-client-ts) instead of hand-written code. The background is described in Authentication.

Services without a user: client credentials​

A background service or a device uses the client credentials flow with its own client id and secret. This is covered in tutorial 5.

Step 2: Your first query​

curl -s "$OCTO_GRAPHQL" \
-H "Authorization: Bearer $OCTO_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ runtime { energyCommunityCustomer(first: 2) { totalCount items { rtId rtWellKnownName } } } }"}'
{
"data": {
"runtime": {
"energyCommunityCustomer": {
"totalCount": 45,
"items": [
{ "rtId": "6ac75edd1f682dc27a1b1d9f", "rtWellKnownName": "customer-018" },
{ "rtId": "6ac75ede1f682dc27a1b1e12", "rtWellKnownName": "customer-041" }
]
}
}
}
}

The root field runtime contains one typed field per CK type of the tenant. Its name is the CK type id in camelCase without separators: EnergyCommunity/Customer becomes energyCommunityCustomer, Basic.Energy/EnergyMeasurement becomes basicEnergyEnergyMeasurement.

Step 3: Runtime entities, filters and paging​

Select attributes​

Typed fields expose the attributes of the type as GraphQL fields, records as nested objects:

query Customers {
runtime {
energyCommunityCustomer(first: 10) {
totalCount
items {
rtId
customerNumber
contact { firstName lastName companyName }
}
}
}
}

Filter​

fieldFilter filters on attribute values. Several filters are combined with AND:

query ConsumptionAnchors {
runtime {
basicEnergyEnergyMeasurement(
first: 50
fieldFilter: [{ attributePath: "obisCode", operator: EQUALS, comparisonValue: "1-1:1.9.0 G.01" }]
) {
totalCount
items { rtId rtWellKnownName obisCode }
}
}
}

All operators, text search and sorting are described in Search and filter.

Page through large results​

Connections are paged with first (page size) and after (cursor). Read pageInfo.endCursor and pass it as after until hasNextPage is false:

query Page($after: String) {
runtime {
basicEnergyEnergyMeasurement(first: 500, after: $after) {
pageInfo { hasNextPage endCursor }
items { rtId obisCode }
}
}
}

Always set first. Without a page size a query returns everything it matches, which is slow on large tenants.

Step 4: Follow associations​

Associations appear as navigation fields named after the association role, for example facilities on a customer or children on a facility. Because the target of a role can have several types, a navigation field requires the list of target types (ckTypeIds) and returns a union. Select the fields per type with an inline fragment (... on <GraphQLTypeName>):

query CustomerTree {
runtime {
energyCommunityCustomer(first: 2) {
items {
customerNumber
facilities(ckTypeIds: ["Basic.Energy/OperatingFacility"]) {
items {
... on BasicEnergyOperatingFacility {
name
children(ckTypeIds: ["EnergyCommunity/Consumer", "EnergyCommunity/Producer"]) {
items {
... on EnergyCommunityConsumer { ckTypeId meteringPointNumber partitionFactor }
... on EnergyCommunityProducer { ckTypeId meteringPointNumber productionType }
}
}
}
}
}
}
}
}
}
{
"customerNumber": "20041",
"facilities": {
"items": [
{
"name": "PV plant 041",
"children": {
"items": [
{
"ckTypeId": "EnergyCommunity/Producer",
"meteringPointNumber": "AT0099990000090012000000000000009",
"productionType": "SOLAR"
}
]
}
}
]
}
}

The opposite direction works the same way: an energy measurement anchor reaches its metering point through parent(ckTypeIds: [...]). For queries over any association role and transitive relations see Associations.

Step 5: Query stream data​

Time series are read from an archive, addressed by its rtId. Find the archives of your tenant in the Studio under Archives, or ask GraphQL which archive family (the base archive and all its rollups) covers which time range:

query Coverage {
streamData {
coverageFor(rtId: "<archive-rtId>") {
archiveRtId
rtWellKnownName
status
isBase
bucketSizeMs
bucketAlignment
storedFunctions
availableFrom
availableTo
}
}
}

Ad-hoc queries go through streamData.transientStreamDataQuery, which offers four query kinds: simple (rows), aggregation, groupingAggregation and downsampling. The result has the column list in columns and the data in rows, a paged connection of rows with cells.

Raw values (15-minute windows)​

The energy-community raw archive is a time-range archive: each row covers a window [window_start, window_end), here 15 minutes. rtIds restricts the query to the anchors you are interested in, arg.from/arg.to to a time range (UTC):

query RawValues($archive: OctoObjectId!, $anchor: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
simple(
archiveRtId: $archive
rtIds: [$anchor]
columnPaths: ["window_start", "window_end", "amount.value", "amount.unit", "obisCode", "dataQuality"]
arg: { from: $from, to: $to, queryMode: DEFAULT }
sortOrder: [{ attributePath: "window_start", sortOrder: ASCENDING }]
) {
items {
columns { attributePath }
rows(first: 96) {
totalCount
pageInfo { hasNextPage endCursor }
items { rtId timestamp cells { items { attributePath value } } }
}
}
}
}
}
}

With from = 2026-10-05T22:00:00Z and to = 2026-10-06T22:00:00Z (one calendar day in Vienna, summer time) the result has totalCount: 96, one row per quarter hour:

{
"rtId": "6ac75edd1f682dc27a1b1dba",
"timestamp": "2026-10-05T22:15:00Z",
"cells": {
"items": [
{ "attributePath": "window_start", "value": "2026-10-05T22:00:00Z" },
{ "attributePath": "window_end", "value": "2026-10-05T22:15:00Z" },
{ "attributePath": "amount.value", "value": 0.065513 },
{ "attributePath": "amount.unit", "value": "KWh" },
{ "attributePath": "obisCode", "value": "1-1:1.9.0 G.01" },
{ "attributePath": "dataQuality", "value": "L1" }
]
}
}

Column paths are the CK attribute paths in camelCase (amount.value, obisCode); time-range archives add the window columns window_start and window_end. Rows are paged with rows(first:, after:) exactly like runtime connections.

Sums: SUM per register for a day​

groupingAggregation aggregates on the server. This query sums all 15-minute values of one day per OBIS code, over all anchors of the tenant:

query DailyTotalsByObis($archive: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
groupingAggregation(
archiveRtId: $archive
groupByColumnPaths: ["obisCode"]
columnPaths: [{ attributePath: "amount.value", aggregationType: SUM }]
arg: { from: $from, to: $to, queryMode: DEFAULT }
) {
items {
columns { attributePath }
rows { items { cells { items { attributePath value } } } }
}
}
}
}
}

The result columns are obisCode and amountvalue_sum, for example:

obisCodeamountvalue_sum (kWh)
1-1:1.9.0 G.01431.36
1-1:2.9.0 G.01474.27
1-1:2.9.0 G.01T474.01
1-1:2.9.0 G.03207.63
1-1:2.9.0 P.01T266.38

Tutorial 3 explains what these registers mean.

Rollups: SUM per day or month without touching raw data​

Summing a year of 15-minute values means reading about 35,000 rows per anchor. Rollup archives hold the sums already. In a rollup the aggregated columns are named <column>_<function>, for example amountvalue_sum and dataquality_max. Which logical attribute a rollup aggregates is returned by streamData.rollupQueryMetadata(rtId:).

Daily values of one anchor from the daily rollup:

query DailyRollup($archive: OctoObjectId!, $anchor: OctoObjectId!, $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
simple(
archiveRtId: $archive
rtIds: [$anchor]
columnPaths: ["window_start", "window_end", "amountvalue_sum", "dataquality_max"]
arg: { from: $from, to: $to, queryMode: DEFAULT }
sortOrder: [{ attributePath: "window_start", sortOrder: ASCENDING }]
) {
items { rows(first: 31) { totalCount items { cells { items { attributePath value } } } } }
}
}
}
}

Calendar rollups are aligned to a reference time zone (for example Europe/Vienna), so a "day" starts at local midnight: 22:00Z in summer, 23:00Z in winter. Pass from and to on these boundaries.

To get monthly totals for a group of anchors, query the monthly rollup with the list of anchor ids and group by the window start, as the Python example below does.

Step 6: A complete Python example​

The script computes, per month, two key figures of an energy community that tutorial 3 explains: the share of consumption covered from within the community and the share of offered generation that was not distributed. It combines everything from this tutorial: a filtered runtime query with paging to find the anchors of each register, and a grouping aggregation over the monthly rollup.

monthly_kpis.py
"""Monthly coverage ratio and surplus ratio of an energy community.

export OCTO_GRAPHQL=https://assets.<your-domain>/tenants/<tenant>/GraphQL
export OCTO_TOKEN=... # see step 1
python monthly_kpis.py 2026-06 2026-09
"""
import os
import sys
from datetime import datetime
from zoneinfo import ZoneInfo

import requests

ENDPOINT = os.environ["OCTO_GRAPHQL"]
TOKEN = os.environ["OCTO_TOKEN"]
VIENNA = ZoneInfo("Europe/Vienna")
MONTHLY_ROLLUP = "<monthly-rollup-rtId>" # e.g. the archive "energy-measurements-monthly"

REGISTERS = {
"consumption": "1-1:1.9.0 G.01",
"self_coverage": "1-1:2.9.0 G.03",
"offered": "1-1:2.9.0 G.01T",
"surplus": "1-1:2.9.0 P.01T",
}

ANCHORS_QUERY = """
query Anchors($obis: SimpleScalar!, $after: String) {
runtime {
basicEnergyEnergyMeasurement(
first: 500, after: $after,
fieldFilter: [{ attributePath: "obisCode", operator: EQUALS, comparisonValue: $obis }]
) {
pageInfo { hasNextPage endCursor }
items { rtId }
}
}
}
"""

MONTHLY_QUERY = """
query Monthly($archive: OctoObjectId!, $rtIds: [OctoObjectId], $from: DateTime!, $to: DateTime!) {
streamData {
transientStreamDataQuery {
groupingAggregation(
archiveRtId: $archive
rtIds: $rtIds
groupByColumnPaths: ["window_start"]
columnPaths: [{ attributePath: "amountvalue_sum", aggregationType: SUM }]
arg: { from: $from, to: $to, queryMode: DEFAULT }
) {
items { rows { items { cells { items { attributePath value } } } } }
}
}
}
}
"""


def graphql(query: str, variables: dict) -> dict:
response = requests.post(
ENDPOINT,
json={"query": query, "variables": variables},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=60,
)
response.raise_for_status() # 401, 400 (invalid query), 5xx
body = response.json()
if body.get("errors"): # execution errors arrive with HTTP 200!
raise RuntimeError(body["errors"][0]["message"])
return body["data"]


def anchors_for(obis: str) -> list[str]:
ids, after = [], None
while True:
page = graphql(ANCHORS_QUERY, {"obis": obis, "after": after})
conn = page["runtime"]["basicEnergyEnergyMeasurement"]
ids += [item["rtId"] for item in conn["items"]]
if not conn["pageInfo"]["hasNextPage"]:
return ids
after = conn["pageInfo"]["endCursor"]


def month_start(year: int, month: int) -> str:
"""Local midnight in Europe/Vienna, expressed in UTC."""
return datetime(year, month, 1, tzinfo=VIENNA).astimezone(ZoneInfo("UTC")).isoformat()


def monthly_sums(rt_ids: list[str], first: str, last: str) -> dict[str, float]:
y1, m1 = map(int, first.split("-"))
y2, m2 = map(int, last.split("-"))
y2, m2 = (y2 + 1, 1) if m2 == 12 else (y2, m2 + 1) # exclusive upper bound
data = graphql(MONTHLY_QUERY, {
"archive": MONTHLY_ROLLUP,
"rtIds": rt_ids,
"from": month_start(y1, m1),
"to": month_start(y2, m2),
})
result = {}
rows = data["streamData"]["transientStreamDataQuery"]["groupingAggregation"]["items"][0]["rows"]["items"]
for row in rows:
cells = {c["attributePath"]: c["value"] for c in row["cells"]["items"]}
local = datetime.fromisoformat(cells["window_start"].replace("Z", "+00:00")).astimezone(VIENNA)
result[local.strftime("%Y-%m")] = cells["amountvalue_sum_sum"]
return result


def main() -> None:
first, last = sys.argv[1], sys.argv[2]
sums = {name: monthly_sums(anchors_for(obis), first, last) for name, obis in REGISTERS.items()}
print(f"{'month':8} {'consumption':>12} {'self-cov.':>10} {'coverage':>9} {'surplus %':>9}")
for month in sorted(sums["consumption"]):
consumption = sums["consumption"].get(month, 0.0)
covered = sums["self_coverage"].get(month, 0.0)
offered = sums["offered"].get(month, 0.0)
surplus = sums["surplus"].get(month, 0.0)
coverage = covered / consumption if consumption else 0.0
surplus_ratio = surplus / offered if offered else 0.0
print(f"{month:8} {consumption:12.1f} {covered:10.1f} {coverage:9.1%} {surplus_ratio:9.1%}")


if __name__ == "__main__":
main()

Sample output:

month consumption self-cov. coverage surplus %
2026-06 12940.8 7950.0 61.4% 61.7%
2026-07 13372.1 8093.9 60.5% 61.4%
2026-08 13372.1 7623.0 57.0% 60.2%
2026-09 12737.3 6720.7 52.8% 58.1%

Typical pitfalls​

GraphQL errors come with HTTP 200. Only transport problems produce an HTTP error status: 401 for a missing or expired token, 400 for a query that does not match the schema. A query that is valid but fails during execution (an unknown column path, a missing permission on a field, a server-side exception) returns HTTP 200 with an errors array and null data for the failed part:

{
"errors": [{ "message": "Invalid column paths: timeRange.from", "extensions": { "code": "ASSET1004" } }],
"data": { "streamData": { "transientStreamDataQuery": { "simple": null } } }
}

Always check errors in the response body, as graphql() in the example does.

Omitting attributeNames returns every attribute. The generic runtimeEntities query exposes attributes as name/value pairs through attributes(attributeNames: [...]). If you leave the argument out you get all attributes of the entity, including credentials stored on configuration entities. Always pass an explicit, non-empty list. The names are matched in camelCase only (["host"] works, ["Host"] returns nothing), and when you read a record attribute, list the record's sub-attributes as well. Typed queries such as energyCommunityCustomer { contact { ... } } do not have this problem because you select each field explicitly. See Secret attributes.

Use the exact endpoint path. It is /tenants/<tenant>/GraphQL on the asset repository host, not on the Studio host and not without /tenants/.

Time zones. Archives store UTC. Calendar rollups are cut at local midnight of their reference time zone. Convert local day or month boundaries to UTC before you query, as month_start() does.

Partial windows. A time-range query returns every window that overlaps [from, to). Put from and to on the window grid (full quarter hours for the raw archive, local midnight for daily rollups), otherwise a window that is only partly inside your range is counted with its full value.

Type names. The typed GraphQL field is camelCase (basicEnergyOperatingFacility), the GraphQL type used in fragments is PascalCase (BasicEnergyOperatingFacility), and filters and ckTypeIds use the CK type id (Basic.Energy/OperatingFacility).

Summary​

  • Get a token from octo-cli for experiments, through Authorization Code + PKCE for your own user-facing app, or through client credentials for a service.
  • runtime has a typed field per CK type, with fieldFilter, paging and navigation fields for associations.
  • streamData.transientStreamDataQuery reads raw rows, aggregates and groups on the server; rollup archives deliver daily or monthly sums directly.
  • Check the errors array even when the HTTP status is 200.

Next: Understanding energy data explains the model and registers you have just queried.