How BattINFO is built#

An orientation map for someone who knows Python, pydantic, and JSON Schema at a working level and wants to understand how this package fits together — before reading source code or contributing. Ten minutes, no ontology background needed.

The one-paragraph version#

BattINFO turns battery data into plain JSON files with a contract. You author records through pydantic models (or a friendlier workspace wrapper); each record is saved as a JSON file that validates against a canonical JSON Schema and carries a permanent identifier; a mapping-driven transform can then render any record as EMMO-aligned JSON-LD for the semantic web. Everything else in the package — CLI, importers, publishing, registry client — is plumbing around that pipeline:

your data ──► authoring surface ──► pydantic models ──► JSON records on disk
                                                              │
                                        JSON Schemas validate ┤
                                                              ▼
                                              JSON-LD (EMMO semantics)
                                                              ▼
                                            Zenodo DOI · Battery Genome registry

Two ideas everything follows#

1. Everything is a spec or an instance. A spec is a reusable description (a cell product’s datasheet, a test protocol); an instance is a physical or concrete realization (the cell on your bench, the test you ran Tuesday). The record types chain into a provenance line:

cell spec ──► cell ──► test ──► dataset        (+ test spec, materials, components…)

Every record type is one entry in a single registry (src/battinfo/entities.py): its JSON discriminator key, its schema file, its on-disk folder, its IRI namespace. Adding a record type means adding one entry there — every dispatch table in the package derives from it.

2. The JSON Schema is the contract, and the pydantic models are how you write to it. There is no separate “input DTO” layer: CellSpec, Cell, Test, TestSpec, and Dataset, written by hand in src/battinfo/bundle.py, are at once

  • the authoring input — construct them with flat datasheet-style kwargs (manufacturer=, nominal_capacity={"value": 2.5, "unit": "Ah"}),

  • the validator — extra="forbid" plus did-you-mean errors, so a typo’d field teaches instead of disappearing,

  • the serializer — to_record() / from_record() convert to and from the canonical JSON shape.

Every save then validates the record against the JSON Schema in src/battinfo/data/schemas/, which is the save-time contract, and tests/test_schema_model_parity.py checks that every schema property can be reached from the models, apart from a listed set of known gaps.

If you know pydantic, you already know the core of BattINFO.

The layers, top to bottom#

One workspace, plus the record classes for scripting#

You are…

Use

Module

Working with data files (the usual case)

ws = battinfo.workspace(".") → convert / search / add / save / publish (ws.quickstart() prints the recipe)

ws.py

Scripting records directly — building a cell spec in code, batch-generating records

The record classes with their save_* functions, or battinfo.publish(...) for a CellSpec

bundle.py, api.py

Both run on the same internal object-graph engine (_workspace.py), which finalizes links and mints IRIs at save time — an implementation detail, not a third surface (the old top-level Workspace export is deprecated). The save_*/query_* functions in api.py are the record-file-level operations everything shares.

Records on disk — the actual product#

A record is one JSON file: a schema_version, one discriminator key holding the body ("cell_spec": {...}), and a provenance block (which also carries battinfo_version, stamping which library build wrote it). Files live under a source root (examples/<type>/… in this repo; .battinfo/records/… in a workspace). IRIs like https://w3id.org/battinfo/spec/pge5-wer6-2q82-v9k0 are minted deterministically from each record’s natural identity (manufacturer :: model :: … for a spec), so re-running an identical ingest lands on the same files instead of duplicating them.

Validation — one contract, three consumers#

The canonical JSON Schemas (draft 2020-12) live in src/battinfo/data/schemas/ and are enforced in three places, each with a CI drift gate: this package (validate/ — schema, then semantic rules, cross-record references, and SHACL), the registry’s publish gate (vendored copy), and the browser validator at battinfo.org/validate (vendored copy compiled with Ajv). Same record, same verdict, everywhere. Details in the infrastructure contract.

The semantic layer — mapping tables, not magic#

record_to_jsonld() (jsonld.py + transform/) turns a record into EMMO-aligned JSON-LD. It is table-driven: curated mapping files in assets/mappings/domain-battery/ say that the key nominal_capacity is emmo:NominalCapacity and the unit "Ah" is emmo:AmpereHour. The transform stacks types (a cylindrical LFP cell is simultaneously BatteryCell, CylindricalBattery, LithiumIonIronPhosphateBattery) and emits the canonical EMMO quantity pattern. No inference, no LLMs, no network — deterministic output from those tables, which is why the website can show real before/after pairs generated at build time.

Two files carry the EMMO annotations in Python. bundle_generated.py holds the IRI metadata. It was generated from the LinkML schema once and has been edited by hand since, so do not regenerate it; regenerating or retiring it is planned for 0.9. Most code reaches it through bundle_adapter.py.

Getting data in and out — interop/ and BDF#

Importers (interop/) accept records from other ecosystems — BPX, PyBaMM experiments, aurora-unicycler protocols, Battery Data Commons, converter JSON-LD, spreadsheet exports — as an adoption funnel: express your existing data in BattINFO first, adopt natively later. Raw cycler files are a separate concern: ws.convert() (backed by the bdf package) normalises instrument exports into one documented table format before any records exist.

Publishing — the payoff#

publication.py builds the publication package (schema.org JSON-LD, RO-Crate, DataCite); zenodo.py archives it for a DOI; api.py’s registry client submits records to the Battery Genome registry (staged for curator review), with retries, a resumable outcome journal, and structured conflict responses. ws.publish() is the one-call wrapper over all of it.

The CLI — the same functions, spelled differently#

cli.py is a typer app; every command wraps a function from api.py or the workspace. Nothing exists “only in the CLI”, which is why the CLI reference can be generated from the app itself.

Where things live#

src/battinfo/
├── bundle.py            the five record models — START HERE
├── entities.py          the record-type registry (one entry per type)
├── ws.py                AuthoringWorkspace: convert/search/add/save/publish
├── _workspace.py        Workspace engine: object graph, finalize, IRI minting
├── api.py               save_*/query_*/template_* + registry client
├── validate/            schema → semantic → references → SHACL layers
├── jsonld.py, transform/  record → EMMO JSON-LD (mapping-table driven)
├── interop/             importers from other ecosystems
├── publication.py, zenodo.py, _publish.py   publication package, DOI, publish()
├── cli.py               typer CLI over the same api functions
├── data/schemas/        the canonical JSON Schemas (the contract)
├── data/examples/       packaged canonical example records
└── bundle_generated.py + bundle_adapter.py   EMMO-annotated schema layer (generated once, edited by hand since)

Supporting cast: _jsonio.py (atomic JSON file I/O), _fs_guard.py (the one place that deletes folders, the checks it makes first, and the check on the paths a workspace manifest names), _record_index.py (the bulk-save id→path cache), _deposit_files.py (the data files of a Zenodo deposit: which locations it may read, names, URLs and checksums, what a deposit would leave out, and the check of its files before a publish), _deposit_index.py (.battinfo/deposit-files.json, the local data files a workspace registered and the local file behind each copy its deposits and uploads made, and .battinfo/zenodo.json and .battinfo/zenodo-sandbox.json, the Zenodo record it continues on zenodo.org and on the sandbox), canonical_aliases.py (legacy key normalisation), testmethod.py (structured test-method steps), metadata.py (CSVW/DCAT dataset enrichment).

How it stays correct#

The repo leans hard on generated-and-drift-checked artifacts: if two things must agree, one is generated from the other and a test fails when they diverge. The guide notebooks and doc snippets execute in CI; the website’s hero recipe is asserted against ws.quickstart(); the CLI and schema reference pages regenerate from the code; the registry and website re-vendor the schemas behind CI gates. When you change behavior, expect a test to point at every place the story is told — that is by design.

Where to go next#