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 ¶
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:
LoaderResultper 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
RawColsnames,epoch_time_utcas int64 ns UTC,datapoint_numas 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 ¶
Describe every registered loader — for print_instruments and docs.
clear_registry ¶
Forget discovery results (tests, and after installing a plugin).
find_loader ¶
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 ¶
The registered loaders, keyed by name. Discovers on first use.
register ¶
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
RawColsname. 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.
Nonemeans "treat naive timestamps as UTC" (decision 2026-07-21, #560, aligning withcellpycore.timestamps) — recorded onTestMeta.time_zoneso 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.
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:
- post hooks (vendor quirks, on vendor column names)
- rename vendor → native, dropping undeclared columns
- timestamps →
epoch_time_utcint64 ns UTC - elapsed-time strings → float seconds (
duration_columns) - cast to the schema dtypes
- reset-granularity normalization to cycle-cumulative
- identity and provenance stamping
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_framestrictly. The two sanctioned warn-only escape hatches (local_instrumentand friends, conventions plan §4) passstrict=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 ¶
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_granularityper 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 liveModelParametersinstance (a loader'sconfig_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:
-
LoaderDeclarations–A validated :class:
LoaderDeclarations.
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 ¶
Split a configuration's renaming dict into native, passthrough, unknown.
Parameters:
Returns:
-
dict[str, str]–(column_map, passthrough, unknown)wherecolumn_mapis -
dict[str, str]–vendor → native for attributes cellpy-core can map,
passthroughis -
list[str]–vendor → legacy column name for real data with no native column yet,
-
tuple[dict[str, str], dict[str, str], list[str]]–and
unknownlists 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 ¶
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:
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 ¶
Routing calls can_load(); a slow sniff makes every load slow.
check_capabilities ¶
The class declares what the registry needs to route it.
check_determinism ¶
Loaders are stateless across calls.
check_loader ¶
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:
-
AssertionError–naming the contract promise that was broken.
check_meta_is_a_draft ¶
The loader fills what the file knows — never provenance.
check_raw_frame ¶
The frame is polars, in the harmonized-raw shape.
check_reset_granularity ¶
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 ¶
load() returns a tuple of LoaderResult — always a tuple.
check_units ¶
raw_units is a validated CellpyUnits, not an ad-hoc dict.