Linking records
Parts, references, and records that sit between two others, with the routes that walk the graph.
Records relate to each other in three ways. Two are link attributes on a record, declared with type: object (one record) or type: objects (a list) and a relation. The third is a type whose records are the relationship, declared with a between attribute. Which one you pick decides what a write replaces, what a delete takes with it, and how you query the far side.
Two questions settle it:
- Does the relationship itself need attributes or a timeseries? Then it is its own object type with a
betweenattribute. Otherwise it is a link attribute on one of the two records. - Does the record on the far end belong to this one alone, and die with it? Then the link's
relationispart. Otherwise it is areference.
Parts
A part has one owner, a name unique among its siblings, and is deleted with the owner. A site owns its assets through the built-in assets attribute; here a plant owns its turbines:
requests.post(f"{BASE}/objects", headers=HEADERS, json={
"name": "plants",
"singular": "Plant",
"plural": "Plants",
"attributes": [
{"slug": "fuel", "title": "Fuel", "type": "select", "options": ["wind", "solar", "hydro"]},
{"slug": "turbines", "title": "Turbines", "type": "objects",
"targets": ["turbines"], "relation": "part"},
],
}).raise_for_status()A part attribute needs at least one target type, and two part attributes on one type may not claim the same target: the part's own type is what says which attribute it belongs to.
Parts are created either inside their owner, all or nothing and one level deep (a nested record carries name and values only; set its own links once it exists):
r = requests.post(f"{BASE}/objects/plants", headers=HEADERS, json={
"name": "Windy Ridge",
"values": {
"fuel": "wind",
"turbines": [
{"name": "T1", "values": {"rating_kw": 4200}},
{"name": "T2", "values": {"rating_kw": 4200}},
],
},
})
r.raise_for_status()
plant = r.json()or on their own endpoint with parent_id:
requests.post(f"{BASE}/objects/turbines", headers=HEADERS, json={
"name": "T3",
"parent_id": plant["id"],
"values": {"rating_kw": 4200},
}).raise_for_status()The owner's type must declare a part attribute accepting the part's type (422 otherwise, 404 for an unknown owner). Rules to know:
- The owner reads its parts back inlined as whole records, one level deep, each with
parent_idandparent_namein the envelope. PATCH {"parent_id": other}on a custom part moves it to another owner, keeping its id and its timeseries. A part cannot be detached:parent_id: nullis a422.- Part lists are additive on
PATCH: an entry creates a new part, and nothing is ever removed by omission. - Deleting the owner deletes its parts and their timeseries.
GET /objects/{type}?parent_id=<id>lists the parts of one owner.
References
A reference points at a record that lives independently. The same zone can be referenced from a synchronous area and from a country; cutting either reference leaves the zone where it is. reference is the default relation.
requests.post(f"{BASE}/objects", headers=HEADERS, json={
"name": "sync-areas",
"singular": "Synchronous area",
"plural": "Synchronous areas",
"attributes": [
{"slug": "zones", "title": "Zones", "type": "objects", "targets": ["zones"]},
],
}).raise_for_status()
r = requests.post(f"{BASE}/objects/sync-areas", headers=HEADERS, json={
"name": "Nordic",
"values": {"zones": [se1_id, se2_id, se3_id, se4_id]},
})
r.raise_for_status()
area = r.json()
print(area["values"]["zones"][0])
# {"id": "...", "name": "SE1", "object": "zones"}- References take the ids of records that already exist, never new records.
targetslists the allowed types, ornullfor any record type. - A reference reads back as
{"id", "name", "object"}. Once the target is deleted,nameandobjectarenullwhile the pointer remains. - A
PATCHon a reference list writes the whole set: what it leaves out is unlinked. The records on the far end are untouched. - Cycles are rejected with
409. - Deleting a record with reference attributes removes only the links.
?delete_members=truealso deletes the referenced records, with their parts and timeseries. It is never implied.
The link routes
Every attribute that holds records gets its own route under the record. The record itself carries the same links inline, capped; these routes page the whole thing and change one link at a time.
| Route | Purpose |
|---|---|
GET /objects/{type}/{id}/{attribute} | The records the attribute holds, unabridged. ?include_geometry=true adds each record's geometry. |
PUT /objects/{type}/{id}/{attribute}/{target_id} | Put one record in. Idempotent. A part attribute moves the target under this record; a reference just links it. 409 on a cycle. |
DELETE /objects/{type}/{id}/{attribute}/{target_id} | Take one record out (204). Refused with 400 on a part attribute: a part is removed by deleting it or moving it. |
Each listed entry carries id, name, object, member_count, connection_count, and a representative latitude/longitude, which is what a tree view needs to decide whether a node expands. Geometry is opt-in because polygons are far larger than the rest of the row.
requests.put(f"{BASE}/objects/sync-areas/{area['id']}/zones/{no1_id}", headers=HEADERS).raise_for_status()
r = requests.get(f"{BASE}/objects/sync-areas/{area['id']}/zones", headers=HEADERS,
params={"include_geometry": "true"})
r.raise_for_status()
print([(z["name"], z["geometry"]["type"]) for z in r.json()["items"]])
# [('SE1', 'MultiPolygon'), ('SE2', 'MultiPolygon'), ...]Records between two records
A relationship that carries data of its own is not a separate concept. It is an object type whose schema includes the two records it runs between: one attribute of type between, which always holds exactly two records. An interconnection has capacities and a flow series, so it is a type of its own:
requests.post(f"{BASE}/objects", headers=HEADERS, json={
"name": "interconnections",
"singular": "Interconnection",
"plural": "Interconnections",
"icon": "🔀",
"attributes": [
{"slug": "zones", "title": "Zones", "type": "between",
"targets": ["zones"], "directed": False},
{"slug": "capacity_forward_mw", "title": "Capacity forward (MW)", "type": "number"},
{"slug": "capacity_reverse_mw", "title": "Capacity reverse (MW)", "type": "number"},
],
}).raise_for_status()The attribute can be called whatever fits: zones for a border, parties for a contract, windings for a transformer. It takes targets like any link, is always required, and is written and read as a two-item list in a fixed order:
r = requests.post(f"{BASE}/objects/interconnections", headers=HEADERS, json={
"values": {
"zones": [se3_id, se4_id],
"capacity_forward_mw": 5300,
"capacity_reverse_mw": 2000,
},
})
r.raise_for_status()
icx = r.json()
a, b = icx["values"]["zones"]
print(a["name"], "<->", b["name"], icx["values"]["capacity_forward_mw"])name is optional here: the unnamed record is the one between its pair, and a name only distinguishes a second record between the same two (parallel circuits).
When the two positions play different roles or accept different types, describe them with ends instead of one targets list:
{"slug": "flow", "title": "Flow", "type": "between", "directed": True,
"ends": [{"title": "Source", "targets": ["zones"]},
{"title": "Sink", "targets": ["plants"]}]}Everything else (list, get, patch, delete, filters, fields, export, stats) is the generic record surface. The rules:
- Both ends are required and target-checked like any other link (
404for an unknown id,422for the wrong type). The two must differ, there are exactly two, and aPATCHcannot clear the list. directed: false(the default): one record per unordered pair. Creating[B, A]when[A, B]exists is a409. This is the guard that stops the same physical link being entered twice. The order you created it in is kept, and it is what tellscapacity_forward_mwfromcapacity_reverse_mwand makes a signed flow series read positive from the first end to the second.directed: true:[A, B]and[B, A]are two different records.- A second unnamed record between the same pair is a
409; give it anameto model parallel circuits. PATCHwith a new list moves the ends; the record keeps its id and its timeseries.- Deleting it deletes only the record, never the two it ran between.
- Because such a record is stored as an edge, nothing can hold it: it cannot be an end, a part, or a reference target. It cannot own parts or hold lists either, so the only extra link attributes such a type may declare are single references.
- The
betweenattribute cannot be added, dropped, or reshaped after the type is created.
Timeseries belong to the record, which is the point: a flow or a transfer capacity is a property of the link, not of either end.
requests.post(f"{BASE}/objects/interconnections/{icx['id']}/timeseries", headers=HEADERS, json={
"type": "flow",
"shape": "singleindex",
"unit": "MW",
# Signed: positive means from the first end to the second.
"points": [{"valid_time": "2026-09-11T00:00:00Z", "value": 812.5}],
}).raise_for_status()What runs through a record
GET /objects/{type}/{record_id}/connections is the reverse of a between attribute: every record that has this one as one of its two ends, answered from an index rather than by scanning. A zone's interconnections, a substation's cables.
r = requests.get(f"{BASE}/objects/zones/{se3_id}/connections", headers=HEADERS)
r.raise_for_status()
for c in r.json()["items"]:
glyph = "<->" if not c["directed"] else ("->" if c["direction"] == "out" else "<-")
print("SE3", glyph, c["counterpart_name"], c["object"], c["id"])Each entry names the connecting record (id, object, name), its two ends (from_id/from_name/from_object, to_id/...), and, relative to the record you asked about, direction (out when it is the from end, in otherwise) and the counterpart_id/counterpart_name/counterpart_object on the other side. directed says whether direction matters. Together that is enough to read a signed flow series correctly without inspecting the ends yourself.
Filtering by links
On custom types, ?filter[...] works on link attributes too:
| Filter | Matches |
|---|---|
filter[zone]=<id> | Records whose object attribute zone points at that record. |
filter[zones]=<id> | Records of a between type touching that record at either end. |
filter[zones.0]=<id>, filter[zones.1]=<id> | Only one position of the pair. |