Skip to content

Instruments

The loader contract, the registry that finds loaders, and the normalization stage that turns a vendor file into cellpy's harmonized raw format.

See Write an instrument loader plugin for the guide version of this.

The contract

contract

The instrument-loader contract (cellpy 2, issue #210).

One formal contract every loader satisfies — built-in or third-party — so the framework can discover, validate and route loaders without knowing anything about the vendor formats behind them.

The contract is a :class:typing.Protocol, not a base class. A third-party loader inherits nothing and imports nothing from cellpy; conformance is structural. That is what makes an out-of-tree loader a first-class citizen rather than a special case (architecture plan §5.1.1).

load() always returns a tuple, even for a single test

Maintainer decision, 2026-07-19, frozen with the Protocol in 2.0: every caller writes one unpacking path, and a format that grows multi-test support later does not silently become a breaking change for its consumers. A loader for a single-test format returns a 1-tuple.

What a loader fills in, and what it must not

A loader fills only what the file actually knows. Provenance — where the file came from, when it was read, what identity it was given — is stamped by the framework, because the loader is not in a position to know it. A draft TestMeta arriving with source_uri already set is a contract violation, and the conformance kit rejects it (architecture plan §5.1.3).

See also

cellpy.readers.instruments.registry (discovery and routing) and cellpy.readers.instruments.testing (the conformance kit).

InstrumentLoader

Bases: Protocol

Structural contract for instrument loaders — the behaviour half.

Implementations need not import or inherit anything from cellpy. Register via the cellpy.loaders entry-point group; see :mod:cellpy.readers.instruments.registry.

A conforming loader also declares the :class:LoaderCapabilities attributes (name, instrument, supported_suffixes); they are not members of this Protocol only so that issubclass() keeps working — the registry validates them separately and rejects a loader that omits them.

Loaders must be stateless across calls: two load() calls on the same source give equal results, and nothing from one call leaks into the next. The registry routes on the class-level capability attributes without instantiating, so those must not depend on instance state.

can_load

can_load(source: Path) -> bool

Cheap sniff — suffix or magic bytes. Must not fully parse the file.

The registry calls this while choosing between candidates, so a slow implementation makes every load slow. The conformance kit puts a time budget on it.

load

load(source: Path, *, instrument_config: object | None = None, **kwargs: object) -> tuple[LoaderResult, ...]

Parse one source into one result per test it contains.

Parameters:

  • source (Path) –

    a local path. The framework has already resolved any remote/OtherPath source to a local copy — a loader never encodes ssh or scheme semantics (architecture plan §5.1.8).

  • instrument_config (object | None, default: None ) –

    the typed per-instrument configuration model, when the framework has one for this loader.

  • **kwargs (object, default: {} ) –

    loader-specific knobs. Unknown ones must be ignorable, so that a caller passing a knob meant for another loader is not an error.

Returns:

  • One ( LoaderResult ) –

    class:LoaderResult per test in the source — always a tuple,

  • ...

    length 1 for single-test formats.

Raises:

  • LoaderError

    wrapping any vendor parse failure. Never return a partial or empty result to signal failure (conventions §1).

LoaderCapabilities

Bases: Protocol

The class-level metadata the registry routes on.

Kept in its own Protocol for a language reason: a runtime_checkable Protocol carrying non-method members cannot be used with issubclass(), and the registry must check a loader class without instantiating it. So the runtime structural check lives on :class:InstrumentLoader (methods only) and these attributes are validated explicitly by the registry, which can also say precisely which one is missing. Use this Protocol for static typing.

LoaderResult dataclass

LoaderResult(raw: 'pl.DataFrame', raw_units: 'CellpyUnits', test_meta: 'TestMeta', cell_meta: 'CellMeta | None' = None, warnings: tuple[str, ...] = ())

One test's worth of parsed data, on its way into the framework.

Attributes:

  • raw ('pl.DataFrame') –

    the harmonized-raw frame — native RawCols names, epoch_time_utc as int64 ns UTC, datapoint_num as a column (never an index).

  • raw_units ('CellpyUnits') –

    a validated CellpyUnits; every label pint-parsable. Never an ad-hoc dict and never float scale factors.

  • test_meta ('TestMeta') –

    a draft TestMeta — only fields the source file knows. Provenance fields must be left empty for the framework.

  • cell_meta ('CellMeta | None') –

    an optional partial CellMeta, for the rare file that carries masses or areas.

  • warnings (tuple[str, ...]) –

    non-fatal notes the framework surfaces to the user. A fatal problem is a LoaderError, never a partial result.

Discovery and routing

registry

Loader discovery and routing via entry points (cellpy 2, issue #210).

Third-party loaders install themselves by declaring an entry point; cellpy finds them without any registration call and without a shared base class::

# in the loader package's pyproject.toml
[project.entry-points."cellpy.loaders"]
mycycler = "cellpy_mycycler.loader:MyCyclerLoader"

Discovery is lazy — the entry-point group is read on first use, not at import, so import cellpy neither scans nor imports third-party code.

Failures are contained and loud in the right way: one broken plugin makes that plugin unavailable with a clear warning, it does not take down cellpy. A plugin that loads but does not satisfy the contract is rejected at registration with a message naming what it is missing, rather than failing later inside a load with something obscure.

Scope note (2026-07-19): the built-in loaders do not route through this registry yet — they still go through the module-globbing InstrumentFactory. Moving them over is part of the loader port (#560), after they emit LoaderResult (#559). Until then this registry is the path for out-of-tree loaders, and the query API below reports both populations.

available_loaders

available_loaders() -> dict[str, dict[str, object]]

Describe every registered loader — for print_instruments and docs.

clear_registry

clear_registry() -> None

Forget discovery results (tests, and after installing a plugin).

find_loader

find_loader(source: Path | None = None, *, instrument: str | None = None) -> type | None

Pick a loader for source, or return None if none applies.

Routing order (architecture plan §5.3): an explicit instrument wins; otherwise candidates are narrowed by suffix and confirmed with the loader's own cheap can_load() sniff.

get_registry

get_registry(*, refresh: bool = False) -> dict[str, type]

The registered loaders, keyed by name. Discovers on first use.

register

register(loader_cls: type) -> None

Register a loader directly, bypassing entry points.

For tests and for notebook-defined loaders. Packaged loaders should declare an entry point instead so they are found without a call.

Declarations and normalization

declarations

Loader declarations — normalization described, not coded (issue #559).

A loader keeps the part that is genuinely hard and vendor-specific (parsing the file) and declares everything after it: which vendor column is which native column, what the units are, how timestamps should be read, and — the dangerous one — what reset granularity the vendor's cumulative columns use.

Declarations are validated when they are constructed, which is when the configuration module is imported. A typo'd native column name is then an import error naming the typo, instead of a confusing failure in the middle of someone's load six months later (loader plan §2.2).

LoaderDeclarations dataclass

LoaderDeclarations(column_map: Mapping[str, str], raw_units: CellpyUnits, timezone: str | None = None, reset_granularity: Mapping[str, ResetGranularity] = dict(), aux_map: Mapping[str, str] = dict(), passthrough: Mapping[str, str] = dict(), post_hooks: tuple[Callable, ...] = (), dropped: tuple[str, ...] = (), duration_columns: tuple[str, ...] = (), datetime_kind: str | None = None)

Everything harmonize() needs to turn a vendor frame into native raw.

Parameters:

  • column_map (Mapping[str, str]) –

    vendor column name → native RawCols name. Vendor columns not mentioned here are dropped; native names must exist.

  • raw_units (CellpyUnits) –

    the units the file is in, as a validated CellpyUnits.

  • timezone (str | None, default: None ) –

    IANA zone for naive vendor timestamps. None means "treat naive timestamps as UTC" (decision 2026-07-21, #560, aligning with cellpycore.timestamps) — recorded on TestMeta.time_zone so the assumption is visible later. Set it when the cycler's local zone is known, so absolute times are correct rather than assumed.

  • reset_granularity (Mapping[str, ResetGranularity], default: dict() ) –

    native cumulative column → its granularity in the vendor file. Columns not listed are assumed already cycle- cumulative, which is the common case and the target convention.

  • aux_map (Mapping[str, str], default: dict() ) –

    vendor column → aux_<quantity>_<name>.

  • passthrough (Mapping[str, str], default: dict() ) –

    vendor column → the name it keeps, for data the native schema has no column for. See the note below.

  • post_hooks (tuple[Callable, ...], default: () ) –

    callables applied to the vendor frame before renaming, for quirks no declaration can express (e.g. Maccor's single signed capacity column that must be split by direction).

On passthrough. harmonize() drops undeclared columns, which is what stops vendor junk leaking into native frames. But some real measurements have no native column yet — energies are the live example (cellpy-core#139), and date_time may never get one. Dropping those would lose data that the current to_native() path preserves, so they are carried through under a stated name instead.

This is deliberately a transitional escape hatch, and it is derived rather than hand-written (see declarations_from_configuration): when the legacy⇄native mapping gains an entry, that column moves from passthrough into column_map on its own, with no loader edit.

all_columns property

all_columns: tuple[str, ...]

Every column the loader emits, passthrough included.

native_columns property

native_columns: tuple[str, ...]

Native columns this loader produces, aux included.

ResetGranularity

Bases: StrEnum

How often a vendor's cumulative column resets to zero.

The harmonized-raw target is cycle-cumulative: within a cycle the value accumulates across that cycle's steps for its direction, and resets at each cycle boundary. harmonize() converts the other two granularities to it.

Getting this wrong does not raise — it silently changes every capacity in the dataset. It is declared per column, and the property test in tests/test_harmonize.py is what actually catches a wrong declaration.

harmonize

The shared normalization stage: vendor frame → harmonized native raw (#559).

Every loader used to do its own renaming, casting, timestamp conversion and capacity fiddling. harmonize() does it once, for all of them, driven by the loader's :class:~cellpy.readers.instruments.declarations.LoaderDeclarations::

vendor file ──parse()──► vendor frame + declarations ──harmonize()──► native raw

The order of operations matters and is fixed here:

  1. post hooks (vendor quirks, on vendor column names)
  2. rename vendor → native, dropping undeclared columns
  3. timestamps → epoch_time_utc int64 ns UTC
  4. elapsed-time strings → float seconds (duration_columns)
  5. cast to the schema dtypes
  6. reset-granularity normalization to cycle-cumulative
  7. identity and provenance stamping
  8. validate_raw_frame

Step 6 is the one to be careful about; see :func:normalize_reset_granularity. Step 4 must precede step 5: the schema dtype for those columns is numeric, so casting a duration string first would null the column outright — see :func:_cast_to_schema, which now refuses to do that quietly.

harmonize

harmonize(vendor_frame: Any, declarations: LoaderDeclarations, *, test_id: int = 0, strict: bool = True) -> pl.DataFrame

Normalize a parsed vendor frame into harmonized native raw.

Parameters:

  • vendor_frame (Any) –

    what the loader's parse() produced — a polars frame, or a pandas frame (converted here, so legacy parsers can be reused unchanged during the port).

  • declarations (LoaderDeclarations) –

    the loader's declarations.

  • test_id (int, default: 0 ) –

    identity for the rows of this test.

  • strict (bool, default: True ) –

    run validate_raw_frame strictly. The two sanctioned warn-only escape hatches (local_instrument and friends, conventions plan §4) pass strict=False.

Returns:

  • DataFrame

    A polars frame in the harmonized-raw schema.

Raises:

  • LoaderError

    if the frame cannot be normalized or fails validation.

normalize_reset_granularity

normalize_reset_granularity(raw: DataFrame, declarations: LoaderDeclarations) -> pl.DataFrame

Convert cumulative columns to the harmonized-raw convention.

The target (harmonized-raw spec, Capacity convention) is cumulative per cycle, per direction: within a cycle the value accumulates across that cycle's steps, and resets to 0 at each cycle boundary. The summary path depends on this — per-cycle capacity is read from the cycle's last datapoint — so a wrong granularity here corrupts every capacity downstream without raising anything.

Conversions:

PER_CYCLE Already the target. Left untouched. PER_TEST Never resets, so the whole history is baked in. Subtract the value the column held entering each cycle. PER_STEP Resets each step, so within a cycle we must re-accumulate: add the running total of the completed steps of that cycle.

Parameters:

  • raw (DataFrame) –

    frame already renamed to native columns.

  • declarations (LoaderDeclarations) –

    carries reset_granularity per column.

Returns:

  • DataFrame

    A new frame; the input is not mutated.

stamp_provenance

stamp_provenance(test_meta: Any, *, source: Any, source_type: str, source_uuid: str | None = None) -> Any

Fill the provenance fields a loader is not allowed to fill itself.

The loader knows what the file says; only the framework knows where the file came from and when it was read (architecture plan §5.4).

config_declarations

Derive loader declarations from the existing configurations (issue #560).

Every configurations/* module already says how its vendor columns map to the legacy header attributes, and cellpy-core already says how legacy attributes map to native columns. Composing the two gives vendor → native without anyone retyping sixteen column maps::

config: legacy_attr -> vendor        (normal_headers_renaming_dict)
core:   legacy_attr -> native        (mapping.LEGACY_ATTR_TO_SCHEMA)
------------------------------------------------------------------
derived: vendor -> native

Deriving beats transcribing for the obvious reason — a hand-copied map is a place for silent typos, and a wrong column mapping produces plausible numbers rather than an error. It also makes the port self-updating: a legacy attribute with no native counterpart lands in passthrough today and moves into column_map by itself the moment cellpy-core adds the entry (the live case is the energy columns, cellpy-core#139).

This module does not change how anything loads. It builds declarations so they can be compared against the legacy path (see tests/test_derived_declarations.py); switching ingestion over is separate.

declarations_from_configuration

declarations_from_configuration(config: ModuleType | Any, *, reset_granularity: dict[str, ResetGranularity] | None = None, post_hooks: tuple = (), timezone: str | None = None) -> LoaderDeclarations

Build validated declarations from an existing configuration module.

Parameters:

  • config (ModuleType | Any) –

    a cellpy.readers.instruments.configurations.* module, or a live ModelParameters instance (a loader's config_params). Both expose the same attribute names, which is what lets the port derive declarations from a loader that is already running.

  • reset_granularity (dict[str, ResetGranularity] | None, default: None ) –

    overrides for columns whose vendor granularity is not the harmonized-raw default. Read the vendor's data before setting one — a wrong value here silently rescales capacities.

  • post_hooks (tuple, default: () ) –

    vendor-quirk callables (e.g. capacity splitting).

  • timezone (str | None, default: None ) –

    IANA zone for naive vendor timestamps.

Returns:

Raises:

  • LoaderError

    if the derived declarations do not validate — which is the point: it happens at import of the configuration, not mid-load.

declarations_from_renaming

declarations_from_renaming(raw_units: dict[str, str], renaming: dict[str, str], datetime_kind: str | None, parsed: bool, loader_name: str, *, post_hooks: tuple = ()) -> LoaderDeclarations

Build hand-written loaders' declarations from their module renaming dict.

Shared by the Arbin SQL variants (arbin_sql, arbin_sql_csv, arbin_sql_xlsx, arbin_sql_7), which each carry a {legacy_attr -> vendor} dict just like a configuration but resolve it in code rather than through a configurations/* module.

The dict is first restricted to real cellpy headers, reproducing what the legacy _post_process does when it renames by iterating arbin_headers_normal: the Arbin dicts also carry non-header aliases (e.g. acr_txt, which is not a header) that collide with a real header on the same vendor column — acr_txt and internal_resistance_txt both name Internal_Resistance(Ohm). Deriving from the unfiltered dict would let the first-declared non-header alias win and mis-route the column; the legacy path never sees the alias at all, so neither should the derivation.

derive_column_maps

derive_column_maps(renaming: dict[str, str]) -> tuple[dict[str, str], dict[str, str], list[str]]

Split a configuration's renaming dict into native, passthrough, unknown.

Parameters:

  • renaming (dict[str, str]) –

    legacy_attr -> vendor column, as the configurations declare it.

Returns:

  • dict[str, str]

    (column_map, passthrough, unknown) where column_map is

  • dict[str, str]

    vendor → native for attributes cellpy-core can map, passthrough is

  • list[str]

    vendor → legacy column name for real data with no native column yet,

  • tuple[dict[str, str], dict[str, str], list[str]]

    and unknown lists attributes that are neither — a configuration

  • tuple[dict[str, str], dict[str, str], list[str]]

    naming something no longer recognised anywhere.

A vendor column claimed by more than one legacy attribute is ambiguous: only one target can win, and the other is silently lost. maccor_txt_one does this today, mapping Watt-hr to both power_txt and charge_energy_txt. The first declaration wins here, matching what the legacy path is observed to produce, and the collision is logged rather than resolved silently.

substitute_unit_templates

substitute_unit_templates(renaming: dict[str, str], config: Any) -> dict[str, str]

Resolve {{ unit }} placeholders in vendor column names.

Neware spells its columns with the unit baked in — Current({{ current }}) becomes Current(A). The legacy update_headers_with_units post-processor did this by mutating config_params in place at load time, which makes the substitution a side effect of having loaded something.

That side effect does not reach the configuration module: a ModelParameters instance carries its own copy. So deriving declarations from a module yielded seven neware vendor names still spelled Current({{ current }}) — names no file contains, so those columns were silently unmapped, while deriving from a post-load config_params worked. Same configuration, different answer depending on what had run first.

Doing it here makes the derivation self-contained and order-independent.

Parameters:

  • renaming (dict[str, str]) –

    legacy_attr -> vendor column; not mutated.

  • config (Any) –

    the configuration, for unit_labels / raw_units.

Returns:

  • dict[str, str]

    A new dict with placeholders resolved.

Raises:

  • LoaderError

    if a placeholder names a unit the configuration does not define. Legacy substituted the string "None", producing a column name that matches nothing — a silent unmapped column rather than an error.

Conformance kit

testing

Conformance kit for instrument loaders (cellpy 2, issue #210).

Ships with cellpy so a third-party loader can prove it satisfies the contract without reverse-engineering it from cellpy's internals::

from cellpy.readers.instruments.testing import check_loader

def test_my_loader_conforms():
    check_loader(MyCyclerLoader, fixture=Path("tests/data/sample.mcx"))

Every check corresponds to a promise in :mod:cellpy.readers.instruments.contract; failures raise :class:AssertionError naming the promise that was broken.

check_can_load_is_cheap

check_can_load_is_cheap(loader_cls: type, fixture: Path) -> None

Routing calls can_load(); a slow sniff makes every load slow.

check_capabilities

check_capabilities(loader_cls: type) -> None

The class declares what the registry needs to route it.

check_determinism

check_determinism(loader_cls: type, fixture: Path, **kwargs: Any) -> None

Loaders are stateless across calls.

check_loader

check_loader(loader_cls: type, fixture: Path, **kwargs: Any) -> None

Run the whole conformance suite for one loader against one fixture.

Parameters:

  • loader_cls (type) –

    the loader class (the registry routes on class-level capability metadata, so that is what is checked).

  • fixture (Path) –

    a small committed sample file the loader can parse.

  • **kwargs (Any, default: {} ) –

    forwarded to load().

Raises:

check_meta_is_a_draft

check_meta_is_a_draft(result: LoaderResult) -> None

The loader fills what the file knows — never provenance.

check_raw_frame

check_raw_frame(result: LoaderResult) -> None

The frame is polars, in the harmonized-raw shape.

check_reset_granularity

check_reset_granularity(result: LoaderResult) -> None

Check 7 — reset-granularity sanity on a harmonized raw frame.

A wrong reset_granularity declaration does not raise; it silently rescales capacities (loader plan §5). The full value-parity property test lives in tests/test_harmonize.py; this kit check is the cheap structural gate every conforming loader must pass: when cumulative capacity columns are present, the last value of each cycle is finite and the per-cycle series has one row per distinct cycle_num.

check_result_types

check_result_types(results: Any) -> tuple[LoaderResult, ...]

load() returns a tuple of LoaderResult — always a tuple.

check_units

check_units(result: LoaderResult) -> None

raw_units is a validated CellpyUnits, not an ad-hoc dict.