Combining multiple crates
Sometimes you may want to ask questions across more than one RO-Crate. Separate collections may share a language, a researcher, an institution, a place, or a period of activity. Before joining them, decide which kind of overlap matters for your question. This tutorial loads three crates at once, joins them through a shared language, then uses a map to show another possible connection.
We'll use three PARADISEC collections related to South Efate, Vanuatu: NT1 and NT8, both deposited by Nick Thieberger, and BR1, deposited by Rosey Billington. They were created independently but overlap by design.
These are metadata-only crates: the records describe the recordings, but the audio sits behind PARADISEC's access conditions. Metadata is all we need to find the overlaps.
What you'll learn
- Loading several crates into one graph with
Crate(...). - Why the crates arrive as disconnected islands, and how to see it.
- Reading a type-coloured network before switching to collection colours.
- Choosing what counts as the "same thing" across collections.
- Using a shared identifier to bring matching records together.
- Using
merge_nodesto reveal what the crates share and watch the separate collections join into one connected graph. - Drawing dataset-coloured place footprints to spot a different kind of join you have not made yet.
Running this tutorial
This tutorial uses Jupyter, Plotly, and Shapely alongside crategraph:
1. Load several crates at once
Crate takes more than one path. Each crate is read in turn, and crategraph adds the
crate's directory name to each id so two crates can never collide:
from crategraph import Crate
combined = Crate(
"docs/tutorials/data/paradisec/NT1",
"docs/tutorials/data/paradisec/NT8",
"docs/tutorials/data/paradisec/BR1",
include_root=True,
)
combined
include_root=True keeps each collection's root entity in the graph, because that is
what links a collection to its languages, people, and places. Every node now carries a
prefixed id like NT1/...; the original id is preserved on each node as raw_id,
which we rely on shortly. Because loading does not invent links between separate crates,
the three collections arrive as three disconnected islands.
Before looking for overlaps, look at the graph using crategraph's normal type colours. The legend shows what kind of thing each node is: collection roots, files, languages, people, places, organisations, and so on. These colours do not show which crate a node came from yet.
This view is useful for orientation. It shows that languages are part of the same metadata structure as people, places, and collection records. For the merge question, though, we need to see which crate each node came from.
2. See the duplication
First tag every node with the crate it came from (the prefix on its id), using
annotate_entities to derive a new origin field. Then look at the languages: rather
than reaching into each entity by hand, ask for them as records and sort the table so the
duplication is easy to see.
tagged = combined.annotate_entities(origin=lambda e: e.id.split("/")[0])
langs = tagged.select(entity_types=["Language"]).entity_records(columns=["name", "origin", "raw_id"])
sorted(langs, key=lambda r: (r["name"], r["origin"]))
[{'name': 'Bislama', 'origin': 'BR1', 'raw_id': 'http://www.language-archives.org/language/bis'},
{'name': 'Bislama', 'origin': 'NT1', 'raw_id': 'http://www.language-archives.org/language/bis'},
{'name': 'Efate, South', 'origin': 'BR1', 'raw_id': 'http://www.language-archives.org/language/erk'},
{'name': 'Efate, South', 'origin': 'NT1', 'raw_id': 'http://www.language-archives.org/language/erk'},
{'name': 'Efate, South', 'origin': 'NT8', 'raw_id': 'http://www.language-archives.org/language/erk'},
{'name': 'English', 'origin': 'BR1', 'raw_id': 'http://www.language-archives.org/language/eng'},
{'name': 'Lelepa', 'origin': 'NT1', 'raw_id': 'http://www.language-archives.org/language/lpa'}]
Efate, South appears three times, once each from BR1, NT1, and NT8, and every one of
them carries the identical raw_id, an OLAC
language URI. So the crates already agree on an identifier for the language; the three
nodes are only separate because loading namespaced their ids.
That does not mean language is the only valid way to connect these crates. It is the right key for the question we are asking here: "which collections document the same language?" If the question were about depositors, institutions, places, or recording periods, we would make a different choice and get a different joined graph.
You can see the same split in the network. Colour the graph by origin (simple=True
drops the side panel, whose legend is keyed to entity types rather than our origin
field):
Three clusters, three colours, no lines between them. Each collection hangs off its own root, and the shared South Efate language is duplicated once per crate. Joining them for this question means making those duplicate language nodes into one node.
3. Choose what should match
Now make that choice explicit in the data. For language nodes, use the shared raw_id.
For everything else, keep the prefixed id so those records stay separate. That means
only languages can be joined in this example:
def share_key(e):
"""Shared id for languages; every other node stays distinct."""
return e.get("raw_id") if "Language" in e.types else e.id
keyed = tagged.annotate_entities(share_key=share_key)
4. Merge, and read off what they share
merge_nodes turns records with the same share_key into one node. Because our
language keys are stable URLs, we also keep a small lookup from URL to language name so
the merged result remains readable. The count column tells us how many original
records were brought together:
language_names = {
r["raw_id"]: r["name"]
for r in tagged.select(entity_types=["Language"]).entity_records(columns=["raw_id", "name"])
}
merged = keyed.merge_nodes(by="share_key").annotate_entities(
name=lambda e: language_names.get(e.id, e.id)
)
shared = [r for r in merged.entity_records(columns=["name", "count"]) if r["count"] > 1]
sorted(shared, key=lambda r: -r["count"])
Efate, South has a count of 3: all three collections document it. Bislama, the
national creole, turns up in two. The names are for display; the actual matching still
used the OLAC language URLs in raw_id. merge_nodes gives you a summary view of the
overlap, which is exactly what you want for the question "which languages do these
collections have in common?".
This result follows from the match we chose. If we used raw_id for every kind of
record, people and institutions would join too. If we wanted to compare places, there
would be no simple shared id; we would need to compare the geography instead.
Draw the merged graph the same way we drew the first one. Merging dropped the origin
we added earlier, but we can read it back off each node's id: a node that stayed unique
still wears its NT1/ prefix, while a merged language node does not, so we label those
"shared":
crates = {"NT1", "NT8", "BR1"}
def merged_origin(e):
head = e.id.split("/")[0]
return head if head in crates else "shared"
merged.annotate_entities(origin=merged_origin).visualise(
colour_by="origin", simple=True,
filepath="paradisec-merged-network.html")
The three islands are now one connected graph. The shared language nodes act as bridges between the coloured collection clusters: South Efate connects all three crates, and Bislama connects NT1 and BR1. That is the payoff of the merge. The shared language is no longer copied three times; it is one point of connection between the collections.
5. A map that hints at another possible join
The crates agree on the language. Do they agree on the place? Each one records a South Efate location as a bounding box (its coordinates reached through the same Well-Known Text link the mapping tutorial follows). Plotting those boxes coloured by crate gives a quick look at how the collections sit in space:
Show the map-drawing code
The drawing is incidental to the merge story, so it is tucked away here. The boxes turn out to be nested (one crate covers the whole island, another a single village), so we draw the largest first with a faint fill and solid borders, or the big box would simply hide the small ones.
import plotly.graph_objects as go
from shapely import wkt
def place_wkt(e):
"""A place's WKT, on the place itself or on a linked Geometry."""
raw = (e.get("asWKT") or e.get("geo:asWKT")
or e.related("geo").first(key="asWKT")
or e.related("geo").first(key="geo:asWKT"))
return (raw[0] if isinstance(raw, list) else raw) if raw else None
def hex_to_rgba(colour, alpha):
"""A faint fill from a solid hex colour, so nested boxes stay distinguishable."""
h = colour.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r}, {g}, {b}, {alpha})"
located = tagged.annotate_entities(wkt=place_wkt).select(entity_types=["Place"])
origin_colours = {"NT1": "#c1272d", "NT8": "#1f77b4", "BR1": "#6a3d9a"}
# Largest box first, so the smaller ones end up drawn on top and stay visible.
boxes = [(wkt.loads(r["wkt"]), r["origin"])
for r in located.entity_records(columns=["origin", "wkt"]) if r["wkt"]]
boxes.sort(key=lambda b: -b[0].area)
fig = go.Figure()
for poly, origin in boxes:
xs, ys = poly.exterior.xy
fig.add_trace(go.Scattermap(
lon=list(xs), lat=list(ys), mode="lines", fill="toself",
name=origin, line=dict(color=origin_colours[origin], width=3),
fillcolor=hex_to_rgba(origin_colours[origin], 0.12)))
fig.update_layout(
map=dict(style="open-street-map", center=dict(lat=-17.72, lon=168.33), zoom=8.5),
height=480, margin=dict(l=0, r=0, t=0, b=0), legend_title_text="Origin crate")
fig.show()
The three boxes sit over the same stretch of Efate, nested inside one another at different resolutions, but they are not the same box, and each crate gives its place a different id. Unlike the language records, there is no shared identifier that says "these places are the same". A researcher would have to decide whether nested or overlapping boxes should count as the same place. The map is enough to show the opportunity is real, and also why a place-based connection is a distinct research direction from the language merge.
Next steps
Merging on language is the simplest case because the crates already shared an identifier,
but it is only one defensible connection. Using raw_id for every kind of record
would also bring together the people and institutions these collections have in common
(both NT crates share Nick Thieberger; all three share PARADISEC and the University of
Melbourne). From here, pair this with
Visualising a collection to explore the joined graph, or
Mapping the places in a collection to take the place
reconciliation further.