rebase.energy

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 between attribute. 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 relation is part. Otherwise it is a reference.

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_id and parent_name in 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: null is a 422.
  • 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. targets lists the allowed types, or null for any record type.
  • A reference reads back as {"id", "name", "object"}. Once the target is deleted, name and object are null while the pointer remains.
  • A PATCH on 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=true also deletes the referenced records, with their parts and timeseries. It is never implied.

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.

RoutePurpose
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 (404 for an unknown id, 422 for the wrong type). The two must differ, there are exactly two, and a PATCH cannot clear the list.
  • directed: false (the default): one record per unordered pair. Creating [B, A] when [A, B] exists is a 409. 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 tells capacity_forward_mw from capacity_reverse_mw and 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 a name to model parallel circuits.
  • PATCH with 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 between attribute 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.

On custom types, ?filter[...] works on link attributes too:

FilterMatches
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.

On this page