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#