Guide 3 — Linked records: cells, tests, and datasets#
Guide 2 described a product. This guide records what happened in the lab: the physical cells you tested, the procedure you ran, and the data it produced — the full provenance chain, built with the one workspace object:
CellSpec ─► Cell ─► Test ─► Dataset
│
TestSpec (the procedure the test followed)
Estimated time: 20 minutes Prerequisites: Guide 2 — Describing a cell
Everything writes to _scratch/guide-03/. This is the depth version of Guide 6 — Publish your first dataset, which runs the same chain from a raw cycler file in five minutes.
[1]:
import json
import shutil
from pathlib import Path
# Throwaway scratch folder next to this notebook — fresh on every run, so the
# outputs below are exactly what a first run produces.
SCRATCH = Path("_scratch/guide-03").resolve()
if SCRATCH.exists():
shutil.rmtree(SCRATCH)
SCRATCH.mkdir(parents=True)
import battinfo
ws = battinfo.workspace(root=SCRATCH)
Step 1 — The cell spec#
In a connected session you would reuse the registry’s identity for the product (spec = ws.search("samsung inr21700 50e")[0] — Guide 6 shows that path). Offline, describe it yourself with the CellSpec model — the same object works either way:
[2]:
spec = battinfo.CellSpec(
manufacturer="Samsung SDI",
model="INR21700-50E",
format="cylindrical",
chemistry="Li-ion",
positive_electrode_basis="NMC",
negative_electrode_basis="graphite",
properties={
"nominal_capacity": {"value": 5.0, "unit": "Ah"},
"nominal_voltage": {"value": 3.6, "unit": "V"},
"rated_energy": {"value": 18.0, "unit": "Wh"},
"mass": {"value": 68.0, "unit": "g"},
},
)
print("Cell spec:", spec.manufacturer, spec.model)
Cell spec: Samsung SDI INR21700-50E
Step 2 — The physical cells#
A Cell is one specific physical item with a serial number, always linked to its spec. Register every cell you actually tested:
[3]:
cells = ws.add(
"cell",
spec=spec,
serial_numbers=["A001", "A002"],
production_date="2026-01-15",
)
cell: A001 (IRI auto-assigned)
cell: A002 (IRI auto-assigned)
Step 3 — The test spec#
A TestSpec is the reusable procedure definition — what you planned to do, independent of any one cell. Steps use PyBaMM experiment syntax, so the stored protocol can be handed straight to pybamm.Experiment(steps * cycles); each step also receives an EMMO electrochemistry process type when it can be determined unambiguously from the step text.
Test specs are authored as small draft files (ws.template("test-spec") writes a skeleton to fill in). Here we write the draft directly and load it:
[4]:
PROTOCOL_STEPS = [
"Discharge at 1C for 10 hours or until 2.5 V",
"Rest for 10 minutes",
"Charge at 1C until 4.2 V",
"Hold at 4.2 V until C/20",
"Rest for 10 minutes",
]
draft_path = SCRATCH / "cycle-life.test-spec.json"
draft_path.write_text(json.dumps({
"name": "1C cycle life at 25 °C",
"type": "cycling",
"steps": PROTOCOL_STEPS,
"cycles": 3,
"description": (
"1C CC-CV charge (4.2 V, C/20 cut-off) / 1C CC discharge (2.5 V cut-off). "
"10-minute rest periods. 25 ± 2 °C. PyBaMM-compatible step syntax."
),
}, indent=2), encoding="utf-8")
protocol = ws.load(draft_path)
print("Test spec:", protocol.name)
for i, step in enumerate(protocol.experiment, 1):
print(f" {i}. {step}")
Test spec: 1C cycle life at 25 °C
1. Discharge at 1C for 10 hours or until 2.5 V
2. Rest for 10 minutes
3. Charge at 1C until 4.2 V
4. Hold at 4.2 V until C/20
5. Rest for 10 minutes
How much semantics should a protocol carry? EMMO can express a CC-CV protocol as a full step-by-step process graph — machine-replicable and queryable at step level — but authoring one is heavy. The trade-offs and the current recommendation are discussed in Test specs — semantic depth.
Step 4 — The test and its data#
A Test is one execution of the test spec on one cell. Passing data= attaches the measured file, and the workspace creates the linked Dataset record for it in the same call:
[5]:
data_dir = SCRATCH / "inputs"
data_dir.mkdir(exist_ok=True)
csv_path = data_dir / "sdi-a001-cycle-life.csv"
csv_path.write_text(
"cycle,capacity_ah,coulombic_efficiency\n"
"1,4.98,0.934\n2,4.95,0.998\n3,4.93,0.998\n",
encoding="utf-8",
)
tests = ws.add(
"test",
cell="A001",
spec=protocol,
data=csv_path,
instrument="Biologic VMP-300",
name="1C cycle life, cell A001",
license="CC-BY-4.0",
description="1C cycle-life test on cell A001, April 2026.",
)
test [cycling] on A001 +1 dataset(s)
Step 5 — Save#
ws.save() validates every record against the canonical JSON Schemas (policy strict) and writes them with stable w3id.org/battinfo/... IRIs, minted deterministically from each record’s identity — re-running an identical save updates in place, never duplicates:
[6]:
result = ws.save()
records_root = SCRATCH / ".battinfo/records"
print()
for record_file in sorted(records_root.rglob("*.json")):
print(f" {record_file.parent.name}/{record_file.name}")
Saved 6 record(s) under <repo>\docs\guides\_scratch\guide-03\.battinfo\records:
cell spec Samsung SDI INR21700-50E [created] .battinfo\records\cell-spec\cell-spec-me0t-k16f-eh5y-rq0k.json
cell A001 [created] .battinfo\records\cell-instance\cell-y9xy-kr0v-y5tn-dfj7.json
cell A002 [created] .battinfo\records\cell-instance\cell-3w87-0ddf-ryjg-evxe.json
test spec 1C cycle life at 25 °C [created] .battinfo\records\test-protocol\test-protocol-kxwy-5f5f-f682-hhch.json
test 1C cycle life, cell A001 [created] .battinfo\records\test\test-6nec-h262-tthy-4rnt.json
dataset 1C cycle life, cell A001 data [created] .battinfo\records\dataset\dataset-0rp6-kncv-cyem-qwcd.json
Next: ws.list(verbose=True) to inspect, or ws.publish() to publish.
cell-instance/cell-3w87-0ddf-ryjg-evxe.json
cell-instance/cell-y9xy-kr0v-y5tn-dfj7.json
cell-spec/cell-spec-me0t-k16f-eh5y-rq0k.json
dataset/dataset-0rp6-kncv-cyem-qwcd.json
test/test-6nec-h262-tthy-4rnt.json
test-protocol/test-protocol-kxwy-5f5f-f682-hhch.json
Step 6 — Inspect the chain#
The links are ordinary fields in the saved records — the dataset knows its cell and its test, the test knows its spec. Validation is layered (JSON Schema → Pydantic → JSON-LD; see Guide 1), and validate_record_report gives the machine-readable report for any record:
[7]:
from battinfo import validate_record_report
dataset_file = next((records_root / "dataset").glob("*.json"))
dataset_doc = json.loads(dataset_file.read_text(encoding="utf-8"))
print(json.dumps(dataset_doc["dataset"], indent=2)[:800])
print(" ...")
report = validate_record_report(dataset_doc, source_root=records_root)
print("\nok:", report.ok, "| errors:", len(report.errors),
"| warnings:", len(report.warnings))
{
"id": "https://w3id.org/battinfo/dataset/0rp6-kncv-cyem-qwcd",
"short_id": "0rp6kn",
"identifier": "dataset:0rp6-kncv-cyem-qwcd",
"name": "1C cycle life, cell A001 data",
"license": "CC-BY-4.0",
"access_url": "file:///<repo>/docs/guides/_scratch/guide-03/inputs/sdi-a001-cycle-life.csv",
"created_at": 1783548132,
"modified_at": 1783548132,
"published_at": 1783548132,
"about": [
"https://w3id.org/battinfo/cell/y9xy-kr0v-y5tn-dfj7",
"https://w3id.org/battinfo/test/6nec-h262-tthy-4rnt"
],
"distributions": [
{
"type": "DataDownload",
"content_url": "file:///<repo>/docs/guides/_scratch/guide-03/inputs/sdi-a001-cycle-life.c
...
ok: True | errors: 0 | warnings: 0
Step 7 — Preview the publication#
ws.preview_jsonld() builds the exact JSON-LD document that publishing would produce — one @graph with every linked record — without creating any deposit. ws.export("ttl") serialises the workspace as Turtle or any other RDF format:
[8]:
preview_path = ws.preview_jsonld(
title="INR21700-50E cycle-life walkthrough",
license="CC-BY-4.0",
creators=[{"name": "Jane Smith", "orcid": "0000-0002-1825-0097"}],
)
preview = json.loads(preview_path.read_text(encoding="utf-8"))
for node in preview["@graph"]:
types = node.get("@type", "?")
label = types if isinstance(types, str) else "/".join(types[:2])
uid = str(node.get("@id", "")).rsplit("/", 1)[-1]
print(f" {label:45s} {uid[:40]}")
Gold-standard: PASS (no validation issues)
Written: <repo>\docs\guides\_scratch\guide-03\battinfo.preview.jsonld
Graph nodes: 7
Data files: 1
dcat:Catalog/schema:DataCatalog RECORD_ID
dcat:Dataset/schema:Dataset 0rp6-kncv-cyem-qwcd
BatteryCellSpecification/schema:CreativeWork me0t-k16f-eh5y-rq0k
prov:Plan/schema:HowTo kxwy-5f5f-f682-hhch
BatteryCell/CylindricalBattery 3w87-0ddf-ryjg-evxe
BatteryCell/CylindricalBattery y9xy-kr0v-y5tn-dfj7
BatteryTest/schema:Action 6nec-h262-tthy-4rnt
Step 8 — Publish#
ws.publish() submits the records to the registry review queue (staged — a curator promotes them to the public index); zenodo=True archives the data with a citable DOI first. Both need credentials, so this cell is a guarded no-op without them:
[9]:
import os
if os.environ.get("BATTINFO_API_KEY"):
ws.publish(note="Guide 3 walkthrough — INR21700-50E cycle life")
ws.status()
else:
print("Records are saved and validated locally.")
print("To publish: ws.login(api_key='...') then ws.publish() — see ws.quickstart().")
Records are saved and validated locally.
To publish: ws.login(api_key='...') then ws.publish() — see ws.quickstart().
What the workspace holds#
[10]:
ws.list()
Workspace: <repo>\docs\guides\_scratch\guide-03\.battinfo\records
6 record(s) across 5 type(s):
cell-spec (1)
Samsung SDI INR21700-50E
cell-instance (2)
A002, A001
test (1)
1C cycle life, cell A001
dataset (1)
1C cycle life, cell A001 data
test-protocol (1)
1C cycle life at 25 °C
* 6 record(s) from this session (will be submitted by ws.submit())
[10]:
{'cell-spec': [{'name': 'Samsung SDI INR21700-50E',
'id': 'https://w3id.org/battinfo/spec/me0t-k16f-eh5y-rq0k',
'file': 'cell-spec-me0t-k16f-eh5y-rq0k.json',
'session': True}],
'cell-instance': [{'name': 'A002',
'id': 'https://w3id.org/battinfo/cell/3w87-0ddf-ryjg-evxe',
'file': 'cell-3w87-0ddf-ryjg-evxe.json',
'session': True},
{'name': 'A001',
'id': 'https://w3id.org/battinfo/cell/y9xy-kr0v-y5tn-dfj7',
'file': 'cell-y9xy-kr0v-y5tn-dfj7.json',
'session': True}],
'test': [{'name': '1C cycle life, cell A001',
'id': 'https://w3id.org/battinfo/test/6nec-h262-tthy-4rnt',
'file': 'test-6nec-h262-tthy-4rnt.json',
'session': True}],
'dataset': [{'name': '1C cycle life, cell A001 data',
'id': 'https://w3id.org/battinfo/dataset/0rp6-kncv-cyem-qwcd',
'file': 'dataset-0rp6-kncv-cyem-qwcd.json',
'session': True}],
'test-protocol': [{'name': '1C cycle life at 25 °C',
'id': 'https://w3id.org/battinfo/spec/kxwy-5f5f-f682-hhch',
'file': 'test-protocol-kxwy-5f5f-f682-hhch.json',
'session': True}]}
Next#
Guide 4 — Semantic layer: the JSON-LD behind these records, and how to work with it as RDF
Guide 6 — Publish your first dataset: this same chain, built from a raw cycler export in five stages