cellpycore 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,156 @@
1
+ """Pluggable per-cycle summary extractors.
2
+
3
+ A *summary extractor* is a callable object that derives one or more per-cycle
4
+ columns from the engine frames (``raw`` / ``steps`` / ``summary``) and returns
5
+ them as a small polars frame keyed by the cycle-number column. The summary
6
+ helpers in :mod:`cellpycore.summarizers` accept an extractor so the *what to
7
+ extract* policy can be swapped without touching the engine plumbing (the join
8
+ onto the summary and null handling stay in the helper).
9
+
10
+ The first user is :func:`cellpycore.summarizers.ir_to_summary`, whose default
11
+ :class:`LastIRExtractor` implements the corrected internal-resistance semantics
12
+ (issue #23): per cycle, the internal resistance of the last datapoint of the
13
+ cycle's last charge / discharge step. Developers building on cellpy-core can
14
+ subclass :class:`SummaryExtractor` to plug in their own logic (for example an
15
+ extractor keyed off the dedicated ``"ir"`` step type) and pass it via the
16
+ ``ir_extractor`` keyword argument.
17
+
18
+ This abstraction is intentionally minimal: only the IR extractor is provided
19
+ today. Other per-cycle helpers (C-rate, end-voltage) could adopt it later.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ import polars as pl
27
+
28
+ if TYPE_CHECKING:
29
+ from cellpycore.config import Schema
30
+
31
+
32
+ class SummaryExtractor:
33
+ """Base class for callable per-cycle summary extractors.
34
+
35
+ Subclasses implement :meth:`__call__`, deriving per-cycle values from the
36
+ (polars) engine frames and returning a polars frame keyed by the summary
37
+ cycle-number column (``schema.cycle.cycle_num``) carrying one or more summary
38
+ columns. The caller left-joins the result onto the summary, so cycles the
39
+ extractor omits become missing (the helper decides how to fill them).
40
+ """
41
+
42
+ def __call__(
43
+ self,
44
+ *,
45
+ raw: "pl.DataFrame",
46
+ steps: "pl.DataFrame",
47
+ summary: "pl.DataFrame",
48
+ schema: "Schema",
49
+ ) -> "pl.DataFrame":
50
+ """Return a per-cycle frame keyed by ``schema.cycle.cycle_num``.
51
+
52
+ Args:
53
+ raw: The raw datapoint frame (polars, native schema).
54
+ steps: The per-step table (polars, native schema).
55
+ summary: The per-cycle summary built so far (polars, native schema).
56
+ schema: The column-header schema in use.
57
+
58
+ Returns:
59
+ A polars frame with a ``schema.cycle.cycle_num`` column plus one or
60
+ more derived per-cycle columns.
61
+
62
+ Raises:
63
+ NotImplementedError: Always, unless overridden by a subclass.
64
+ """
65
+ raise NotImplementedError
66
+
67
+
68
+ class LastIRExtractor(SummaryExtractor):
69
+ """Default internal-resistance extractor (issue #23).
70
+
71
+ For each cycle it reads the ``internal_resistance`` of the **last raw
72
+ datapoint** of the cycle's **last charge step** (``ir_charge``) and of the
73
+ cycle's **last discharge step** (``ir_discharge``). The value is taken
74
+ literally (no skipping of zero/null readings). Cycles without a charge
75
+ (resp. discharge) step are simply absent from the returned frame, so the
76
+ caller fills them with ``NaN``.
77
+
78
+ This fixes the legacy off-by-one cycle attribution (the old helper read the
79
+ first datapoint of the *first* charge/discharge step) and makes the
80
+ multiple-step case explicit (the *last* step wins instead of a silent
81
+ ``[0]``).
82
+ """
83
+
84
+ def __call__(
85
+ self,
86
+ *,
87
+ raw: "pl.DataFrame",
88
+ steps: "pl.DataFrame",
89
+ summary: "pl.DataFrame",
90
+ schema: "Schema",
91
+ ) -> "pl.DataFrame":
92
+ headers_raw = schema.raw
93
+ headers_steps = schema.step
94
+ headers_cycle = schema.cycle
95
+
96
+ # Group per (test, cycle, step) when the frames carry a ``test_id`` key so
97
+ # merged objects never mix steps/cycles across tests; otherwise fall back
98
+ # to (cycle, step), byte-identical to the single-test path.
99
+ use_tid = (
100
+ headers_raw.test_id in raw.columns
101
+ and headers_steps.test_id in steps.columns
102
+ )
103
+ raw_step_keys = (
104
+ [headers_raw.test_id, headers_raw.cycle_num, headers_raw.step_num]
105
+ if use_tid
106
+ else [headers_raw.cycle_num, headers_raw.step_num]
107
+ )
108
+ step_group_keys = (
109
+ [headers_steps.test_id, headers_steps.cycle_num]
110
+ if use_tid
111
+ else [headers_steps.cycle_num]
112
+ )
113
+
114
+ # internal resistance of the last raw datapoint of each (cycle, step).
115
+ # maintain_order (no sort) mirrors the raw frame's natural acquisition
116
+ # order, so ``last`` is the chronologically last reading of the step.
117
+ ir_per_step = raw.group_by(raw_step_keys, maintain_order=True).agg(
118
+ pl.col(headers_raw.internal_resistance).last().alias("__ir")
119
+ )
120
+
121
+ def _side(step_type: str, out_name: str) -> "pl.DataFrame":
122
+ last_step = (
123
+ steps.filter(pl.col(headers_steps.step_type) == step_type)
124
+ .group_by(step_group_keys, maintain_order=True)
125
+ .agg(pl.col(headers_steps.step_num).last().alias("__step"))
126
+ )
127
+ left_keys = (
128
+ [headers_steps.test_id, headers_steps.cycle_num, "__step"]
129
+ if use_tid
130
+ else [headers_steps.cycle_num, "__step"]
131
+ )
132
+ select_exprs = [
133
+ pl.col(headers_steps.cycle_num).alias(headers_cycle.cycle_num),
134
+ pl.col("__ir").alias(out_name),
135
+ ]
136
+ if use_tid:
137
+ select_exprs.insert(
138
+ 0, pl.col(headers_steps.test_id).alias(headers_cycle.test_id)
139
+ )
140
+ return last_step.join(
141
+ ir_per_step,
142
+ left_on=left_keys,
143
+ right_on=raw_step_keys,
144
+ how="left",
145
+ ).select(select_exprs)
146
+
147
+ ir_charge = _side("charge", headers_cycle.ir_charge)
148
+ ir_discharge = _side("discharge", headers_cycle.ir_discharge)
149
+ join_on = (
150
+ [headers_cycle.test_id, headers_cycle.cycle_num]
151
+ if use_tid
152
+ else headers_cycle.cycle_num
153
+ )
154
+ return ir_charge.join(
155
+ ir_discharge, on=join_on, how="full", coalesce=True
156
+ )
@@ -0,0 +1,267 @@
1
+ """Authoritative ``config.Cols`` <-> legacy ``Headers*`` column-name mapping.
2
+
3
+ This module is the single source of truth for how the native cellpy-core column
4
+ names (``config.RawCols`` / ``config.StepCols`` / ``config.CycleCols``) translate
5
+ to and from the legacy cellpy names (``legacy.HeadersNormal`` /
6
+ ``legacy.HeadersStepTable`` / ``legacy.HeadersSummary``).
7
+
8
+ The legacy bridge (``cell_core.OldCellpyCellCore``) builds all of its native
9
+ <-> legacy rename dictionaries from the declarations here, so the translation
10
+ lives in exactly one place and is covered by ``tests/test_header_mapping.py``
11
+ (round-trip, totality, and bridge-parity tests).
12
+
13
+ Design notes:
14
+
15
+ - The mapping is defined over **column-name strings** (the values of the
16
+ dataclass fields), not over attribute names. This is what DataFrame renames
17
+ act on, and it side-steps the fact that legacy ``HeadersSummary`` has two
18
+ attributes that share a value (``discharge_capacity`` /
19
+ ``discharge_capacity_raw``).
20
+ - Pairs include **identity pass-throughs** (native string == legacy string,
21
+ e.g. summary ``ir_charge`` / ``charge_c_rate`` / ``normalized_cycle_index``).
22
+ These columns are intentionally not renamed by the bridge (they already share
23
+ a name), but they are real, mapped columns and must be declared so the
24
+ "total" claim holds.
25
+ - "Lossless and total" is defined **modulo the documented exception sets**
26
+ below: every native column is either mapped or listed in a ``NATIVE_ONLY_*``
27
+ set, and every legacy column is either mapped or listed in a ``LEGACY_ONLY_*``
28
+ set. The exception sets are explicit (not derived) so that adding a new column
29
+ on either side fails the totality test until it is deliberately categorised.
30
+
31
+ Step-table granularity: the step engine produces per-signal statistic columns
32
+ (``<signal>_<stat>``). The native/legacy *base-signal* correspondence is declared
33
+ in :data:`STEP_BASE_PAIRS` and expanded with :data:`STAT_SUFFIXES`; scalar
34
+ (non-statistic) step columns are in :data:`STEP_SCALAR_PAIRS`. Note that the
35
+ ``datapoint_num`` and ``test_time`` step signals are declared in ``StepCols`` only
36
+ with ``_first`` / ``_last`` variants (the engine emits just those two stats for
37
+ them), even though they participate in the base-signal mapping.
38
+ """
39
+
40
+ # --- statistic suffixes (native -> legacy) ----------------------------------
41
+ # The per-step engine names statistics ``<signal>_<native_stat>``; legacy cellpy
42
+ # uses ``<signal>_<legacy_stat>`` (only ``mean`` -> ``avr`` actually differs).
43
+ STAT_SUFFIXES = {
44
+ "mean": "avr",
45
+ "std": "std",
46
+ "min": "min",
47
+ "max": "max",
48
+ "first": "first",
49
+ "last": "last",
50
+ "delta": "delta",
51
+ }
52
+
53
+ # --- raw frame (native RawCols <-> legacy HeadersNormal) ---------------------
54
+ # Each entry is ``(native, legacy)``. Only these raw columns cross the bridge;
55
+ # everything else is a documented exception below.
56
+ RAW_PAIRS = [
57
+ ("datapoint_num", "data_point"),
58
+ ("test_time", "test_time"),
59
+ ("step_time", "step_time"),
60
+ ("cycle_num", "cycle_index"),
61
+ ("step_num", "step_index"),
62
+ ("current", "current"),
63
+ ("potential", "voltage"),
64
+ ("cumulative_charge_capacity", "charge_capacity"),
65
+ ("cumulative_discharge_capacity", "discharge_capacity"),
66
+ ("internal_resistance", "internal_resistance"),
67
+ ]
68
+
69
+ # --- step table (native StepCols <-> legacy HeadersStepTable) ----------------
70
+ # Base signals carry the seven ``STAT_SUFFIXES`` variants; ``(native, legacy)``.
71
+ STEP_BASE_PAIRS = [
72
+ ("datapoint_num", "point"),
73
+ ("test_time", "test_time"),
74
+ ("step_time", "step_time"),
75
+ ("current", "current"),
76
+ ("potential", "voltage"),
77
+ ("charge_capacity", "charge"),
78
+ ("discharge_capacity", "discharge"),
79
+ ("internal_resistance", "ir"),
80
+ ]
81
+
82
+ # Scalar (non-statistic) step columns; ``(native, legacy)``.
83
+ STEP_SCALAR_PAIRS = [
84
+ ("cycle_num", "cycle"),
85
+ ("step_num", "step"),
86
+ ("sub_step_num", "sub_step"),
87
+ ("step_type", "type"),
88
+ ("sub_step_type", "sub_type"),
89
+ ("c_rate", "rate_avr"),
90
+ ]
91
+
92
+ # --- cycle / summary (native CycleCols <-> legacy HeadersSummary) ------------
93
+ # ``(native, legacy)``. Includes identity pass-throughs (last block) so the
94
+ # totality claim holds; the bridge treats those as no-op renames.
95
+ CYCLE_PAIRS = [
96
+ ("cycle_num", "cycle_index"),
97
+ ("datapoint_num_last", "data_point"),
98
+ ("last_test_time", "test_time"),
99
+ ("charge_capacity", "charge_capacity"),
100
+ ("discharge_capacity", "discharge_capacity"),
101
+ ("coulombic_efficiency", "coulombic_efficiency"),
102
+ ("coulombic_difference", "coulombic_difference"),
103
+ ("charge_capacity_loss", "charge_capacity_loss"),
104
+ ("discharge_capacity_loss", "discharge_capacity_loss"),
105
+ ("test_cumulated_charge_capacity", "cumulated_charge_capacity"),
106
+ ("test_cumulated_discharge_capacity", "cumulated_discharge_capacity"),
107
+ ("test_cumulated_coulombic_difference", "cumulated_coulombic_difference"),
108
+ ("test_cumulated_charge_capacity_loss", "cumulated_charge_capacity_loss"),
109
+ ("test_cumulated_discharge_capacity_loss", "cumulated_discharge_capacity_loss"),
110
+ ("potential_end_charge", "end_voltage_charge"),
111
+ ("potential_end_discharge", "end_voltage_discharge"),
112
+ ("temperature_cell_mean", "temperature_mean"),
113
+ ("temperature_cell_last", "temperature_last"),
114
+ # Identity pass-throughs (native name already equals the legacy name).
115
+ ("ir_charge", "ir_charge"),
116
+ ("ir_discharge", "ir_discharge"),
117
+ ("charge_c_rate", "charge_c_rate"),
118
+ ("discharge_c_rate", "discharge_c_rate"),
119
+ ("normalized_cycle_index", "normalized_cycle_index"),
120
+ ]
121
+
122
+ # -----------------------------------------------------------------------------
123
+ # Documented exceptions (columns with no counterpart on the other side).
124
+ # These make "lossless/total" well-defined; the totality test asserts that the
125
+ # declared columns of each class equal (mapped columns) ∪ (its exception set).
126
+ # -----------------------------------------------------------------------------
127
+
128
+ # Legacy HeadersNormal column values with no native RawCols counterpart.
129
+ # (``test_id`` exists on both sides with the same name but is intentionally not
130
+ # translated by the raw bridge, so it is listed as an exception on both sides.)
131
+ LEGACY_ONLY_RAW = frozenset({
132
+ "aci_phase_angle", "ref_aci_phase_angle", "ac_impedance", "ref_ac_impedance",
133
+ "charge_energy", "date_time", "discharge_energy", "power", "is_fc_data",
134
+ "sub_step_index", "sub_step_time", "test_id", "reference_voltage", "dv_dt",
135
+ "frequency", "amplitude", "channel_id", "data_flag", "test_name",
136
+ })
137
+
138
+ # Native RawCols column values with no legacy HeadersNormal counterpart.
139
+ # (``ref_potential`` is deliberately not bridged to legacy ``reference_voltage``:
140
+ # legacy HeadersStepTable has no reference-voltage aggregates, so bridging the
141
+ # raw column would grow the legacy step frame and break byte parity. The signal
142
+ # is native-path only; see issue #43.)
143
+ NATIVE_ONLY_RAW = frozenset({
144
+ "source_datapoint_num", "mask", "epoch_time_utc", "source_type",
145
+ "source_uuid", "test_id", "source_step_num", "step_type", "step_type_detail",
146
+ "step_mode", "cycle_type", "cumulative_charge_energy",
147
+ "cumulative_discharge_energy", "step_charge_power", "step_discharge_power",
148
+ "ref_potential",
149
+ "aux_temperature_cell", "aux_temperature_chamber", "aux_pressure_cell",
150
+ })
151
+
152
+ # Legacy HeadersStepTable column values with no native StepCols counterpart.
153
+ # (``ustep`` is emitted by the engine as a literal "ustep" column only when
154
+ # ``usteps=True``; it has no declared StepCols field.)
155
+ LEGACY_ONLY_STEP = frozenset({"test", "ustep", "info", "ir_pct_change"})
156
+
157
+ # Native StepCols *signals* with no legacy counterpart (power / energy
158
+ # statistics, the boolean ``mask``, and the per-test ``test_id`` key). Compared at
159
+ # base-signal granularity, i.e. after stripping the ``STAT_SUFFIXES`` from
160
+ # statistic columns. ``test_id`` is intentionally not step-bridged (it is dropped
161
+ # from the legacy step table), matching the raw-bridge treatment of ``test_id``.
162
+ NATIVE_ONLY_STEP = frozenset(
163
+ {"power", "charge_energy", "discharge_energy", "mask", "test_id",
164
+ "ref_potential"}
165
+ )
166
+
167
+ # Legacy HeadersSummary column values with no native CycleCols counterpart
168
+ # (legacy-only cruft: cumulated CE, shifted / RIC capacities, OCV mins/maxes,
169
+ # normalized capacities, temperatures, levels, passthrough identity columns).
170
+ LEGACY_ONLY_CYCLE = frozenset({
171
+ "date_time", "test_name", "data_flag", "channel_id",
172
+ "cumulated_coulombic_efficiency", "normalized_charge_capacity",
173
+ "normalized_discharge_capacity", "shifted_charge_capacity",
174
+ "shifted_discharge_capacity", "ocv_first_min", "ocv_second_min",
175
+ "ocv_first_max", "ocv_second_max", "cumulated_ric_disconnect",
176
+ "cumulated_ric_sei", "cumulated_ric", "low_level", "high_level",
177
+ "aux_",
178
+ })
179
+
180
+ # Native CycleCols column values with no legacy HeadersSummary counterpart.
181
+ # (``test_id`` is the per-test key; intentionally not summary-bridged, so it is
182
+ # dropped from the legacy summary table like the raw/step ``test_id``.)
183
+ NATIVE_ONLY_CYCLE = frozenset({
184
+ "test_id",
185
+ "mask", "datapoint_num_first", "first_epoch_time_utc", "last_epoch_time_utc",
186
+ "first_test_time", "cycle_duration", "charge_duration", "discharge_duration",
187
+ "rest_duration", "test_net_capacity", "charge_energy", "discharge_energy",
188
+ "cycle_net_energy", "energy_efficiency", "test_cumulated_charge_energy",
189
+ "test_cumulated_discharge_energy", "test_net_energy",
190
+ "current_charge_mean", "current_charge_mean_tw", "current_charge_mean_cw",
191
+ "current_charge_max", "current_charge_min", "current_discharge_mean",
192
+ "current_discharge_mean_tw", "current_discharge_mean_cw",
193
+ "current_discharge_max", "current_discharge_min",
194
+ "potential_charge_mean", "potential_charge_mean_tw", "potential_charge_mean_cw",
195
+ "potential_charge_max", "potential_charge_min", "potential_discharge_mean",
196
+ "potential_discharge_mean_tw", "potential_discharge_mean_cw",
197
+ "potential_discharge_max", "potential_discharge_min",
198
+ "potential_start_charge", "potential_start_discharge", "voltage_efficiency",
199
+ "power_charge_mean", "power_charge_mean_tw", "power_charge_mean_cw",
200
+ "power_charge_max", "power_charge_min", "power_discharge_mean",
201
+ "power_discharge_mean_tw", "power_discharge_mean_cw", "power_discharge_max",
202
+ "power_discharge_min", "ir_start_charge", "ir_end_charge", "ir_start_discharge",
203
+ "ir_end_discharge", "relaxation_potential_charge",
204
+ "relaxation_potential_discharge", "open_circuit_potential_charge",
205
+ "open_circuit_potential_discharge", "cv_share", "cv_charge_capacity",
206
+ "cv_charge_energy", "cv_charge_time", "cc_charge_capacity", "cc_charge_energy",
207
+ "cc_charge_time", "temperature_cell_max", "temperature_cell_min",
208
+ })
209
+
210
+
211
+ # -----------------------------------------------------------------------------
212
+ # Derivation helpers (the bridge builds its rename dicts from these).
213
+ # -----------------------------------------------------------------------------
214
+ def legacy_to_native_raw(columns=None) -> dict:
215
+ """Return the legacy -> native rename dict for the raw frame.
216
+
217
+ Args:
218
+ columns: Optional iterable of column names actually present. When given,
219
+ the result is filtered to keys in ``columns`` (so the dict is safe to
220
+ pass straight to ``DataFrame.rename``).
221
+
222
+ Returns:
223
+ dict: Mapping ``legacy_name -> native_name``.
224
+ """
225
+ mapping = {legacy: native for native, legacy in RAW_PAIRS}
226
+ if columns is not None:
227
+ cols = set(columns)
228
+ mapping = {k: v for k, v in mapping.items() if k in cols}
229
+ return mapping
230
+
231
+
232
+ def native_to_legacy_step() -> dict:
233
+ """Return the native -> legacy rename dict for the step table.
234
+
235
+ Expands :data:`STEP_BASE_PAIRS` with every :data:`STAT_SUFFIXES` variant and
236
+ appends the scalar :data:`STEP_SCALAR_PAIRS`.
237
+
238
+ Returns:
239
+ dict: Mapping ``native_name -> legacy_name``.
240
+ """
241
+ rename = {}
242
+ for native_base, legacy_base in STEP_BASE_PAIRS:
243
+ for native_stat, legacy_stat in STAT_SUFFIXES.items():
244
+ rename[f"{native_base}_{native_stat}"] = f"{legacy_base}_{legacy_stat}"
245
+ for native, legacy in STEP_SCALAR_PAIRS:
246
+ rename[native] = legacy
247
+ return rename
248
+
249
+
250
+ def legacy_to_native_step() -> dict:
251
+ """Return the inverse of :func:`native_to_legacy_step` (legacy -> native)."""
252
+ return {v: k for k, v in native_to_legacy_step().items()}
253
+
254
+
255
+ def native_to_legacy_summary() -> dict:
256
+ """Return the native -> legacy rename dict for the per-cycle summary.
257
+
258
+ Returns:
259
+ dict: Mapping ``native_name -> legacy_name`` (identity pass-throughs
260
+ included; harmless no-op renames for the bridge).
261
+ """
262
+ return {native: legacy for native, legacy in CYCLE_PAIRS}
263
+
264
+
265
+ def legacy_to_native_summary() -> dict:
266
+ """Return the inverse of :func:`native_to_legacy_summary` (legacy -> native)."""
267
+ return {v: k for k, v in native_to_legacy_summary().items()}