Build a cell from components#
Describe a coin cell the way you built it: materials → electrodes → electrolyte/separator/housing → cell spec → physical cells — every layer a saved record, every link an IRI. This whole page is one continuous script; run it top to bottom. Running it again updates the records it saved in place, because every record here gets its IRI from what identifies it.
Ingredients: a library folder (here my-library) and the material specs
from Register materials. All the create_*/save_*
functions live in battinfo.api.
1. Register the parts and harvest their IRIs#
Every save_* call returns a dict whose "id" is the record’s permanent IRI —
capture it, it is how the next layer refers to this one:
from battinfo.api import create_material_spec, save_material_spec
LAB = "my-library"
def material(name, **kw):
return save_material_spec(create_material_spec(name=name, **kw),
source_root=LAB, mode="upsert")["id"]
NMC811_IRI = material("NMC811", kind="nmc811", formula="LiNi0.8Mn0.1Co0.1O2")
GRAPHITE_IRI = material("Graphite", kind="graphite", formula="C")
PVDF_IRI = material("PVDF", kind="pvdf", formula="(C2H2F2)n")
CB_IRI = material("Carbon black", kind="carbon_black", formula="C")
LIPF6_IRI = material("LiPF6", kind="lipf6", formula="LiPF6")
EC_IRI = material("EC", kind="ec", formula="C3H4O3")
EMC_IRI = material("EMC", kind="emc", formula="C4H8O3")
2. The electrodes — a 96/2/2 coating, by IRI#
from battinfo.api import create_electrode_spec, save_electrode_spec
cathode = create_electrode_spec(
name="NMC811 cathode 96/2/2", active_material_kind="nmc811", polarity="positive",
coating={
"component": {
"active_material": [{"name": "NMC811", "material_spec_id": NMC811_IRI,
"property": {"mass_fraction": {"value": 0.96, "unit": "1"}}}],
"binder": [{"name": "PVDF", "material_spec_id": PVDF_IRI,
"property": {"mass_fraction": {"value": 0.02, "unit": "1"}}}],
"additive": [{"name": "Carbon black", "material_spec_id": CB_IRI,
"property": {"mass_fraction": {"value": 0.02, "unit": "1"}}}],
},
"property": {"loading": {"value": 21, "unit": "mg/cm2"},
"thickness": {"value": 75, "unit": "um"}},
},
current_collector={"name": "Aluminium foil",
"property": {"thickness": {"value": 15, "unit": "um"}}})
CATHODE_IRI = save_electrode_spec(cathode, source_root=LAB, mode="upsert")["id"]
anode = create_electrode_spec(
name="Graphite anode", active_material_kind="graphite", polarity="negative",
coating={
"component": {
"active_material": [{"name": "Graphite", "material_spec_id": GRAPHITE_IRI,
"property": {"mass_fraction": {"value": 0.96, "unit": "1"}}}],
"binder": [{"name": "PVDF", "material_spec_id": PVDF_IRI}],
},
"property": {"loading": {"value": 12, "unit": "mg/cm2"}},
},
current_collector={"name": "Copper foil",
"property": {"thickness": {"value": 10, "unit": "um"}}})
ANODE_IRI = save_electrode_spec(anode, source_root=LAB, mode="upsert")["id"]
An electrode spec gets its IRI from its producer, product (here its name),
grade, active-material kind and processing route: the IRI
ws.add("electrode_spec", ...) gives the same electrode. The coating= and
current_collector= shapes mirror the canonical examples in
examples/electrode-spec/
— when in doubt, open one and copy its structure.
3. Electrolyte, separator, housing#
These three do not mint from a natural key yet: without a uid= or id=,
create_component_spec gives each a random IRI, a new one on every call, so a
second run would save a second copy. Derive each uid from what identifies the
part instead:
from battinfo.api import create_component_spec, save_component_spec
from battinfo.entities import stable_uid
def part_uid(family, *key):
"""A uid from the part's natural key: the same part always gets the same IRI."""
return stable_uid("::".join([f"{family}-spec", *key]))
electrolyte = create_component_spec(
"electrolyte", name="1M LiPF6 EC:EMC 3:7",
uid=part_uid("electrolyte", "My Lab", "1M LiPF6 EC:EMC 3:7"),
body={
"family": "organic",
"salt": {"name": "LiPF6", "material_spec_id": LIPF6_IRI,
"property": {"concentration": {"value": 1.0, "unit": "mol/L"}}},
"solvent_mixture": {"component": [
{"name": "EC", "material_spec_id": EC_IRI,
"property": {"volume_fraction": {"value": 0.3, "unit": "1"}}},
{"name": "EMC", "material_spec_id": EMC_IRI,
"property": {"volume_fraction": {"value": 0.7, "unit": "1"}}},
]},
})
ELECTROLYTE_IRI = save_component_spec("electrolyte", electrolyte,
source_root=LAB, mode="upsert")["id"]
separator = create_component_spec(
"separator", name="Celgard 2325", manufacturer="Celgard", product_id="2325",
uid=part_uid("separator", "Celgard", "2325"),
body={"material": "polypropylene", "structure": "trilayer",
"property": {"thickness": {"value": 25, "unit": "um"},
"porosity": {"value": 0.39, "unit": "1"}}})
SEPARATOR_IRI = save_component_spec("separator", separator,
source_root=LAB, mode="upsert")["id"]
housing = create_component_spec(
"housing", name="CR2032 coin housing",
uid=part_uid("housing", "My Lab", "CR2032 coin housing"),
body={"cell_format": "coin",
"case": {"size_code": "2032", "material": "Stainless steel",
"property": {"diameter": {"value": 20, "unit": "mm"},
"height": {"value": 3.2, "unit": "mm"}}}})
HOUSING_IRI = save_component_spec("housing", housing,
source_root=LAB, mode="upsert")["id"]
The first argument names the component family — note it goes before
name=: the electrolyte’s"organic"/"aqueous"classification is thefamilykey insidebody=, not a keyword argument.
The random IRI that create_component_spec and create_component_instance,
and the equipment builders create_equipment_spec and create_equipment,
give a record without a uid= or id= is outside BattINFO’s identity
contract: nothing can derive it again. These builders get natural-key minting
in 0.9. Until then, give every record you will save a uid, as above; the
known limitations list this.
4. The cell spec — all five references#
import battinfo
from battinfo.api import save_cell_spec
cell = battinfo.CellSpec(
manufacturer="My Lab", model="COIN-NMC811-A", format="coin", chemistry="Li-ion",
positive_electrode_spec_id=CATHODE_IRI,
negative_electrode_spec_id=ANODE_IRI,
electrolyte_spec_id=ELECTROLYTE_IRI,
separator_spec_id=SEPARATOR_IRI,
housing_spec_id=HOUSING_IRI,
nominal_capacity={"value": 0.004, "unit": "Ah"},
nominal_voltage={"value": 3.7, "unit": "V"},
)
CELL_IRI = save_cell_spec(cell, source_root=LAB, mode="upsert")["id"]
This is shipped, working behavior — the five *_spec_id reference fields are
part of the CellSpec model today, and the packaged
example cells uses exactly this pattern. A cell may
reference components, inline them, or both (inline holders remain optional —
see Separators,
Current collectors and
Housings).
5. The physical cells#
from battinfo.api import save_cell_instance
for name in ("B7-01", "B7-02", "B7-03"):
save_cell_instance({"cell_spec_id": CELL_IRI, "name": name},
source_root=LAB, mode="upsert")
(For interactive batches — including printing labels — the workspace’s
ws.add("cell", spec=..., names=[...]) does the same thing; see
Label your cells.)
6. Validate the whole set#
Saves check references by default, so a typo in an IRI normally fails at
save_* time. Finish every build session by sweeping the library under the
strict policy anyway: it also catches a reference that went missing after the
save, and the gaps strict validation refuses, such as an electrode spec with no
active-material kind:
import json
from pathlib import Path
from battinfo import validate_record_report
bad = 0
for path in sorted(Path(LAB).rglob("*.json")):
report = validate_record_report(json.loads(path.read_text(encoding="utf-8")),
source_root=LAB, policy="strict")
if not report.ok:
bad += 1
print(f"INVALID {path.name}")
for issue in report.issues:
print(" -", issue.message)
print(f"{bad} invalid record(s)") # 0 invalid record(s)
assert bad == 0
A dangling reference reports as reference.missing with the offending IRI —
fix the reference (or register the missing part) and re-run.
What you have now#
A three-level graph on disk — cell → components → materials — where every node is a strictly valid record with a permanent IRI, and shared parts (that binder, that electrolyte) are registered once and referenced everywhere. Run the page again and it lands on the same files and the same links. The cells page shows the same pattern across the packaged example fleet, and Find existing records shows how to query what you have built.