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.
- cellpycore/__init__.py +0 -0
- cellpycore/_helpers.py +199 -0
- cellpycore/cell_core.py +696 -0
- cellpycore/config.py +674 -0
- cellpycore/extractors.py +156 -0
- cellpycore/header_mapping.py +267 -0
- cellpycore/legacy.py +336 -0
- cellpycore/metadata/__init__.py +59 -0
- cellpycore/metadata/io.py +192 -0
- cellpycore/metadata/models.py +238 -0
- cellpycore/selectors.py +470 -0
- cellpycore/settings_base.py +98 -0
- cellpycore/summarizers.py +903 -0
- cellpycore/timestamps.py +128 -0
- cellpycore/units.py +257 -0
- cellpycore-0.1.1.dist-info/METADATA +90 -0
- cellpycore-0.1.1.dist-info/RECORD +19 -0
- cellpycore-0.1.1.dist-info/WHEEL +4 -0
- cellpycore-0.1.1.dist-info/licenses/LICENSE +21 -0
cellpycore/cell_core.py
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Iterable, Union, Sequence, Optional, List, TypeVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# The CellpyCell (currently named CellpyCellCore) is the main class that the full cellpy package
|
|
9
|
+
# should interact with.
|
|
10
|
+
# The Data class can be accessed through the data property (setter and getter).
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
from cellpycore import config
|
|
14
|
+
from cellpycore import header_mapping
|
|
15
|
+
|
|
16
|
+
from cellpycore.legacy import NoDataFound
|
|
17
|
+
from cellpycore.legacy import Meta, MockMetaTestDependent
|
|
18
|
+
|
|
19
|
+
DataFrame = TypeVar("DataFrame")
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Data:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.meta_test_dependent: Meta = MockMetaTestDependent()
|
|
27
|
+
self.raw: Optional[DataFrame] = None
|
|
28
|
+
# The step table and the per-cycle summary produced by the engine.
|
|
29
|
+
# (``cycle``/``step`` are kept as legacy aliases for backwards
|
|
30
|
+
# compatibility; the engine reads/writes ``steps``/``summary``.)
|
|
31
|
+
self.steps: Optional[DataFrame] = None
|
|
32
|
+
self.summary: Optional[DataFrame] = None
|
|
33
|
+
self.cycle: Optional[DataFrame] = None
|
|
34
|
+
self.step: Optional[DataFrame] = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def has_steps(self) -> bool:
|
|
38
|
+
"""True if a step table has been computed."""
|
|
39
|
+
return self.steps is not None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_summary(self) -> bool:
|
|
43
|
+
"""True if a summary has been computed."""
|
|
44
|
+
return self.summary is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CellpyCellCore: # Rename to CellpyCell when cellpy core is ready
|
|
48
|
+
# TODO: move the data object to slim
|
|
49
|
+
# TODO: copy div settings to slim
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
initialize: bool = False,
|
|
54
|
+
debug: bool = False,
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Args:
|
|
58
|
+
initialize (bool): set to True if you want to initialize the cellpy object with an empty Data instance.
|
|
59
|
+
debug (bool): set to True if you want to see debug messages.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
self.debug = debug
|
|
63
|
+
logger.debug("created CellpyCellCore instance")
|
|
64
|
+
|
|
65
|
+
self._cell_name: Optional[str] = None
|
|
66
|
+
self._cycle_mode: Optional[str] = None
|
|
67
|
+
self._data: Optional[Data] = None
|
|
68
|
+
|
|
69
|
+
self.cellpy_file_name: Optional[str] = None
|
|
70
|
+
self.cellpy_object_created_at: datetime.datetime = datetime.datetime.now()
|
|
71
|
+
self.forced_errors: int = 0
|
|
72
|
+
|
|
73
|
+
# self.capacity_modifiers: List[str] = CAPACITY_MODIFIERS
|
|
74
|
+
# self.list_of_step_types: List[str] = STEP_TYPES
|
|
75
|
+
|
|
76
|
+
# - headers
|
|
77
|
+
self.raw_cols: config.Cols = config.RawCols()
|
|
78
|
+
self.cycle_cols: config.Cols = config.CycleCols()
|
|
79
|
+
self.step_cols: config.Cols = config.StepCols()
|
|
80
|
+
|
|
81
|
+
# Note! units is not used by cellpy core
|
|
82
|
+
if initialize:
|
|
83
|
+
self.initialize()
|
|
84
|
+
|
|
85
|
+
def initialize(self):
|
|
86
|
+
"""Initialize the CellpyCell object with empty Data instance."""
|
|
87
|
+
|
|
88
|
+
logger.debug("Initializing...")
|
|
89
|
+
self._data = Data()
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def data(self) -> Data:
|
|
93
|
+
"""Returns the DataSet instance.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
DataSet instance.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
NoDataFound: If the CellpyCell does not have any data.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
if not self._data:
|
|
103
|
+
raise NoDataFound("The CellpyCell does not have any data.")
|
|
104
|
+
else:
|
|
105
|
+
return self._data
|
|
106
|
+
|
|
107
|
+
@data.setter
|
|
108
|
+
def data(self, new_data: Data):
|
|
109
|
+
"""Sets the Data instance"""
|
|
110
|
+
|
|
111
|
+
self._data = new_data
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def cycle_mode(self) -> str:
|
|
115
|
+
# TODO: v2.0 edit this from scalar to list
|
|
116
|
+
try:
|
|
117
|
+
data = self.data
|
|
118
|
+
m = data.meta_test_dependent.cycle_mode
|
|
119
|
+
# cellpy saves this as a list (ready for v2.0),
|
|
120
|
+
# but we want to return a scalar for the moment
|
|
121
|
+
# Temporary fix to make sure that cycle_mode is a scalar:
|
|
122
|
+
if isinstance(m, (tuple, list)):
|
|
123
|
+
return m[0]
|
|
124
|
+
return m
|
|
125
|
+
except NoDataFound:
|
|
126
|
+
return self._cycle_mode
|
|
127
|
+
|
|
128
|
+
@cycle_mode.setter
|
|
129
|
+
def cycle_mode(self, cycle_mode: str):
|
|
130
|
+
if isinstance(cycle_mode, (tuple, list)):
|
|
131
|
+
cycle_mode = [cycle_mode.lower() for cycle_mode in cycle_mode]
|
|
132
|
+
else:
|
|
133
|
+
cycle_mode = cycle_mode.lower()
|
|
134
|
+
# TODO: v2.0 edit this from scalar to list
|
|
135
|
+
logger.debug(f"-> cycle_mode: {cycle_mode}")
|
|
136
|
+
try:
|
|
137
|
+
data = self.data
|
|
138
|
+
data.meta_test_dependent.cycle_mode = cycle_mode
|
|
139
|
+
self._cycle_mode = cycle_mode
|
|
140
|
+
except NoDataFound:
|
|
141
|
+
self._cycle_mode = cycle_mode
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def schema(self) -> config.Schema:
|
|
145
|
+
"""The column-header schema for this cell.
|
|
146
|
+
|
|
147
|
+
Bundles the raw / cycle (summary) / step header objects so the summary
|
|
148
|
+
and step engine can read their column names from an injected object
|
|
149
|
+
instead of module-level globals. Built on access so subclass overrides of
|
|
150
|
+
``raw_cols`` / ``cycle_cols`` / ``step_cols`` (e.g. the legacy bridge) are
|
|
151
|
+
always reflected.
|
|
152
|
+
"""
|
|
153
|
+
return config.Schema(
|
|
154
|
+
raw=self.raw_cols,
|
|
155
|
+
cycle=self.cycle_cols,
|
|
156
|
+
step=self.step_cols,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def make_core_summary(
|
|
160
|
+
self,
|
|
161
|
+
data: Data,
|
|
162
|
+
selector: Optional[Callable] = None,
|
|
163
|
+
find_ir: bool = True,
|
|
164
|
+
find_end_voltage: bool = False,
|
|
165
|
+
select_columns: bool = True,
|
|
166
|
+
final_data_points: Optional[Iterable[int]] = None,
|
|
167
|
+
current_conversion_factor: float = 1.0,
|
|
168
|
+
ir_extractor: Optional[Callable] = None,
|
|
169
|
+
) -> Data:
|
|
170
|
+
"""Make the core summary.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
data: The data to make the summary from.
|
|
174
|
+
selector: The selector to use.
|
|
175
|
+
find_ir: Whether to find the IR.
|
|
176
|
+
find_end_voltage: Whether to find the end voltage.
|
|
177
|
+
select_columns: Whether to select only the minimum columns that are needed.
|
|
178
|
+
final_data_points: The final data point for each cycle to use for the selector.
|
|
179
|
+
current_conversion_factor: Precomputed factor that converts the raw
|
|
180
|
+
current unit to the desired output current unit for the C-rate
|
|
181
|
+
columns (by value; default 1.0 = no conversion).
|
|
182
|
+
ir_extractor: Optional ``SummaryExtractor`` controlling how the per-cycle
|
|
183
|
+
internal-resistance columns are derived. Defaults to
|
|
184
|
+
``extractors.LastIRExtractor`` when ``None``.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Data object with the summary.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
from cellpycore import summarizers
|
|
191
|
+
|
|
192
|
+
# The native summary engine is polars-native on the native schema and
|
|
193
|
+
# produces the clean ``CycleCols`` subset plus C-rate / IR columns. The
|
|
194
|
+
# remaining legacy-only cruft (cumulated CE, shifted / RIC capacities) is
|
|
195
|
+
# added only on the legacy bridge (``OldCellpyCellCore.make_core_summary``).
|
|
196
|
+
time_00 = time.time()
|
|
197
|
+
logger.debug("start making summary (native polars engine)")
|
|
198
|
+
test_mode = (
|
|
199
|
+
config.TestMode.INVERTED
|
|
200
|
+
if self.cycle_mode == "anode"
|
|
201
|
+
else config.TestMode.NORMAL
|
|
202
|
+
)
|
|
203
|
+
data = summarizers.make_summary(
|
|
204
|
+
data,
|
|
205
|
+
self.schema,
|
|
206
|
+
final_data_points=final_data_points,
|
|
207
|
+
test_mode=test_mode,
|
|
208
|
+
)
|
|
209
|
+
if find_ir and (self.schema.raw.internal_resistance in data.raw.columns):
|
|
210
|
+
data = summarizers.ir_to_summary(
|
|
211
|
+
data, self.schema, ir_extractor=ir_extractor
|
|
212
|
+
)
|
|
213
|
+
data = summarizers.c_rates_to_summary(
|
|
214
|
+
data, self.schema, current_conversion_factor=current_conversion_factor
|
|
215
|
+
)
|
|
216
|
+
logger.debug(f"(dt: {(time.time() - time_00):4.2f}s)")
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
def add_scaled_summary_columns(
|
|
220
|
+
self,
|
|
221
|
+
data: Data,
|
|
222
|
+
nom_cap_abs: float,
|
|
223
|
+
normalization_cycles: Union[Sequence, int, None],
|
|
224
|
+
step_txt: Optional[str] = None,
|
|
225
|
+
specifics: Optional[List[str]] = None,
|
|
226
|
+
specific_converters: Optional[dict] = None,
|
|
227
|
+
) -> Data:
|
|
228
|
+
"""Add specific summary columns to the summary.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
data: The data to add the specific summary columns to.
|
|
232
|
+
nom_cap_abs: The nominal capacity of the cell.
|
|
233
|
+
normalization_cycles: The number of cycles to normalize the data by.
|
|
234
|
+
step_txt: The step text to use (charge or discharge capacity, will pick 'first' based on cycle mode if not provided)
|
|
235
|
+
specifics: The specifics to add.
|
|
236
|
+
specific_converters: Mapping of ``mode -> conversion factor`` supplied
|
|
237
|
+
by value by the caller (so this method needs no unit handling). If
|
|
238
|
+
not provided, the factors are computed lazily via the units helper
|
|
239
|
+
using ``self.cellpy_units`` as a fallback (legacy / standalone).
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The data with the specific summary columns added.
|
|
243
|
+
"""
|
|
244
|
+
from cellpycore import summarizers
|
|
245
|
+
|
|
246
|
+
schema = self.schema
|
|
247
|
+
|
|
248
|
+
if specifics is None:
|
|
249
|
+
specifics = ["gravimetric", "areal", "absolute"]
|
|
250
|
+
|
|
251
|
+
if step_txt is None:
|
|
252
|
+
if self.cycle_mode == "anode":
|
|
253
|
+
step_txt = schema.cycle.discharge_capacity
|
|
254
|
+
else:
|
|
255
|
+
step_txt = schema.cycle.charge_capacity
|
|
256
|
+
|
|
257
|
+
data = summarizers.equivalent_cycles_to_summary(
|
|
258
|
+
data, schema, nom_cap_abs, normalization_cycles, step_txt
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Note: the C-rates are added by make_core_summary (using the step-table
|
|
262
|
+
# rates, independent of nom_cap, matching legacy cellpy). Adding them again
|
|
263
|
+
# here would duplicate the charge_c_rate/discharge_c_rate columns (pandas
|
|
264
|
+
# merge would suffix them _x/_y), so it is intentionally not repeated.
|
|
265
|
+
|
|
266
|
+
specific_columns = schema.cycle.specific_columns
|
|
267
|
+
for mode in specifics:
|
|
268
|
+
converter = self._resolve_specific_converter(
|
|
269
|
+
data, mode, specific_converters
|
|
270
|
+
)
|
|
271
|
+
data = summarizers.generate_specific_summary_columns(
|
|
272
|
+
data, mode, specific_columns, converter
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return data
|
|
276
|
+
|
|
277
|
+
def _resolve_specific_converter(
|
|
278
|
+
self, data: Data, mode: str, specific_converters: Optional[dict]
|
|
279
|
+
) -> float:
|
|
280
|
+
"""Resolve the specific-capacity conversion factor for a mode.
|
|
281
|
+
|
|
282
|
+
Prefers the caller-supplied factor (by value). Falls back to computing it
|
|
283
|
+
via the units helper using ``self.cellpy_units`` (legacy / standalone use);
|
|
284
|
+
this is the only place the summary path may touch pint, and only when the
|
|
285
|
+
caller did not supply the factor.
|
|
286
|
+
"""
|
|
287
|
+
if specific_converters is not None and mode in specific_converters:
|
|
288
|
+
return specific_converters[mode]
|
|
289
|
+
|
|
290
|
+
from cellpycore import units
|
|
291
|
+
|
|
292
|
+
return units.get_converter_to_specific(
|
|
293
|
+
data=data, mode=mode, to_units=getattr(self, "cellpy_units", None)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def make_core_step_table(
|
|
297
|
+
self,
|
|
298
|
+
data: Data,
|
|
299
|
+
raw_limits: Optional[dict] = None,
|
|
300
|
+
step_specifications=None,
|
|
301
|
+
short: bool = False,
|
|
302
|
+
override_step_types: Optional[dict] = None,
|
|
303
|
+
override_raw_limits: Optional[dict] = None,
|
|
304
|
+
usteps: bool = False,
|
|
305
|
+
add_c_rate: bool = True,
|
|
306
|
+
nom_cap: Optional[float] = None,
|
|
307
|
+
skip_steps: Optional[Sequence] = None,
|
|
308
|
+
sort_rows: bool = True,
|
|
309
|
+
from_data_point: Optional[int] = None,
|
|
310
|
+
) -> Union[Data, "DataFrame"]:
|
|
311
|
+
"""Make the core step table.
|
|
312
|
+
|
|
313
|
+
Delegates to ``summarizers.make_step_table`` using this cell's schema.
|
|
314
|
+
The instrument resolution limits (``raw_limits``) and the absolute
|
|
315
|
+
nominal capacity (``nom_cap``, for the C-rate) are supplied by the caller.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
data: The data to make the step table from.
|
|
319
|
+
raw_limits: The instrument resolution limits. If None, the summarizer
|
|
320
|
+
default (DEFAULT_RAW_LIMITS) is used.
|
|
321
|
+
step_specifications: Optional explicit step specifications.
|
|
322
|
+
short: Whether step specifications are in short format.
|
|
323
|
+
override_step_types: Override the detected step types.
|
|
324
|
+
override_raw_limits: Override individual raw limits.
|
|
325
|
+
usteps: Whether to investigate all (sub-)steps within a cycle.
|
|
326
|
+
add_c_rate: Whether to include the per-step C-rate (rate_avr).
|
|
327
|
+
nom_cap: Absolute nominal capacity used for the C-rate (default 1.0).
|
|
328
|
+
skip_steps: Step numbers to skip.
|
|
329
|
+
sort_rows: Whether to sort the rows after processing.
|
|
330
|
+
from_data_point: First data point to use (returns a DataFrame when set).
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Data object with the step table, or a DataFrame when ``from_data_point``
|
|
334
|
+
is given.
|
|
335
|
+
"""
|
|
336
|
+
from cellpycore import summarizers
|
|
337
|
+
|
|
338
|
+
kwargs = dict(
|
|
339
|
+
schema=self.schema,
|
|
340
|
+
step_specifications=step_specifications,
|
|
341
|
+
short=short,
|
|
342
|
+
override_step_types=override_step_types,
|
|
343
|
+
override_raw_limits=override_raw_limits,
|
|
344
|
+
usteps=usteps,
|
|
345
|
+
add_c_rate=add_c_rate,
|
|
346
|
+
nom_cap=nom_cap,
|
|
347
|
+
skip_steps=skip_steps,
|
|
348
|
+
sort_rows=sort_rows,
|
|
349
|
+
from_data_point=from_data_point,
|
|
350
|
+
)
|
|
351
|
+
if raw_limits is not None:
|
|
352
|
+
kwargs["raw_limits"] = raw_limits
|
|
353
|
+
|
|
354
|
+
return summarizers.make_step_table(data, **kwargs)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class OldCellpyCellCore(CellpyCellCore):
|
|
358
|
+
"""Legacy CellpyCellCore class to make it easier to migrate to cellpy core."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, *args, **kwargs):
|
|
361
|
+
from cellpycore.units import get_cellpy_units, get_default_output_units
|
|
362
|
+
from cellpycore.legacy import HeadersNormal, HeadersSummary, HeadersStepTable
|
|
363
|
+
|
|
364
|
+
super().__init__(*args, **kwargs)
|
|
365
|
+
self.cellpy_units = get_cellpy_units()
|
|
366
|
+
self.output_units = get_default_output_units()
|
|
367
|
+
self.raw_cols = HeadersNormal()
|
|
368
|
+
self.cycle_cols = HeadersSummary()
|
|
369
|
+
self.step_cols = HeadersStepTable()
|
|
370
|
+
|
|
371
|
+
# ---- legacy <-> native bridge for the polars step engine ----------------
|
|
372
|
+
# cellpy hands us pandas frames with legacy (``HeadersNormal``) column names.
|
|
373
|
+
# The step engine is polars-native and operates on the native schema, so we
|
|
374
|
+
# translate at this seam: legacy pandas raw -> native polars raw -> engine ->
|
|
375
|
+
# native polars steps -> legacy pandas steps. The output frame reproduces the
|
|
376
|
+
# legacy ``HeadersStepTable`` layout byte-for-byte (the golden oracle).
|
|
377
|
+
#
|
|
378
|
+
# The native <-> legacy correspondence is declared once in
|
|
379
|
+
# ``cellpycore.header_mapping``; the methods below just adapt it to the
|
|
380
|
+
# DataFrame renames this bridge needs (see tests/test_header_mapping.py).
|
|
381
|
+
|
|
382
|
+
def _legacy_to_native_raw_rename(self, columns) -> dict:
|
|
383
|
+
return header_mapping.legacy_to_native_raw(columns)
|
|
384
|
+
|
|
385
|
+
def _native_to_legacy_step_rename(self) -> dict:
|
|
386
|
+
return header_mapping.native_to_legacy_step()
|
|
387
|
+
|
|
388
|
+
def _legacy_step_column_order(self) -> list:
|
|
389
|
+
leg = self.step_cols
|
|
390
|
+
# ``ustep`` is only present when the engine ran with ``usteps=True`` (the
|
|
391
|
+
# native frame names it literally "ustep" == ``leg.ustep``); it is filtered
|
|
392
|
+
# out below when absent, so this is a no-op for the default step table.
|
|
393
|
+
order = [leg.cycle, leg.step, leg.sub_step, leg.ustep]
|
|
394
|
+
bases = [
|
|
395
|
+
leg.point, leg.test_time, leg.step_time, leg.current, leg.voltage,
|
|
396
|
+
leg.charge, leg.discharge, leg.internal_resistance,
|
|
397
|
+
]
|
|
398
|
+
for base in bases:
|
|
399
|
+
order += [f"{base}_{stat}" for stat in header_mapping.STAT_SUFFIXES.values()]
|
|
400
|
+
order += [leg.rate_avr, leg.type, leg.sub_type, leg.info]
|
|
401
|
+
return order
|
|
402
|
+
|
|
403
|
+
def _native_steps_to_legacy(self, native_steps, sort_rows: bool):
|
|
404
|
+
leg = self.step_cols
|
|
405
|
+
pdf = native_steps.to_pandas()
|
|
406
|
+
pdf = pdf.rename(columns=self._native_to_legacy_step_rename())
|
|
407
|
+
|
|
408
|
+
order = self._legacy_step_column_order()
|
|
409
|
+
if sort_rows:
|
|
410
|
+
# sort + reset_index reproduces the legacy 'index' column (the
|
|
411
|
+
# pre-sort, group-key-ordered position).
|
|
412
|
+
pdf = pdf.sort_values(by=f"{leg.test_time}_first").reset_index()
|
|
413
|
+
order = ["index"] + order
|
|
414
|
+
|
|
415
|
+
order = [c for c in order if c in pdf.columns]
|
|
416
|
+
return pdf[order]
|
|
417
|
+
|
|
418
|
+
def make_core_step_table(
|
|
419
|
+
self,
|
|
420
|
+
data: Data,
|
|
421
|
+
raw_limits: Optional[dict] = None,
|
|
422
|
+
step_specifications=None,
|
|
423
|
+
short: bool = False,
|
|
424
|
+
override_step_types: Optional[dict] = None,
|
|
425
|
+
override_raw_limits: Optional[dict] = None,
|
|
426
|
+
usteps: bool = False,
|
|
427
|
+
add_c_rate: bool = True,
|
|
428
|
+
nom_cap: Optional[float] = None,
|
|
429
|
+
skip_steps: Optional[Sequence] = None,
|
|
430
|
+
sort_rows: bool = True,
|
|
431
|
+
from_data_point: Optional[int] = None,
|
|
432
|
+
) -> Union[Data, "DataFrame"]:
|
|
433
|
+
"""Build the step table via the polars engine, in/out in legacy form.
|
|
434
|
+
|
|
435
|
+
See the bridge note above. Returns a pandas frame with legacy
|
|
436
|
+
``HeadersStepTable`` columns (or that frame directly when
|
|
437
|
+
``from_data_point`` is given).
|
|
438
|
+
"""
|
|
439
|
+
import polars as pl
|
|
440
|
+
|
|
441
|
+
from cellpycore import summarizers
|
|
442
|
+
from cellpycore.config import default_schema
|
|
443
|
+
|
|
444
|
+
native_raw = pl.from_pandas(
|
|
445
|
+
data.raw.rename(columns=self._legacy_to_native_raw_rename(data.raw.columns))
|
|
446
|
+
)
|
|
447
|
+
tmp = Data()
|
|
448
|
+
tmp.raw = native_raw
|
|
449
|
+
|
|
450
|
+
kwargs = dict(
|
|
451
|
+
schema=default_schema(),
|
|
452
|
+
step_specifications=step_specifications,
|
|
453
|
+
short=short,
|
|
454
|
+
override_step_types=override_step_types,
|
|
455
|
+
override_raw_limits=override_raw_limits,
|
|
456
|
+
usteps=usteps,
|
|
457
|
+
add_c_rate=add_c_rate,
|
|
458
|
+
nom_cap=nom_cap,
|
|
459
|
+
skip_steps=skip_steps,
|
|
460
|
+
sort_rows=False, # the bridge handles legacy sorting + 'index' column
|
|
461
|
+
from_data_point=from_data_point,
|
|
462
|
+
)
|
|
463
|
+
if raw_limits is not None:
|
|
464
|
+
kwargs["raw_limits"] = raw_limits
|
|
465
|
+
|
|
466
|
+
result = summarizers.make_step_table(tmp, **kwargs)
|
|
467
|
+
native_steps = result if from_data_point is not None else result.steps
|
|
468
|
+
|
|
469
|
+
legacy_steps = self._native_steps_to_legacy(native_steps, sort_rows=sort_rows)
|
|
470
|
+
# The native engine only classifies step ``type`` from the specifications;
|
|
471
|
+
# the legacy ``info`` column is carried here in pandas so NaN entries keep
|
|
472
|
+
# their legacy ``str(NaN) == "nan"`` behaviour.
|
|
473
|
+
if step_specifications is not None:
|
|
474
|
+
legacy_steps = self._apply_spec_info(
|
|
475
|
+
legacy_steps, step_specifications, short
|
|
476
|
+
)
|
|
477
|
+
if from_data_point is not None:
|
|
478
|
+
return legacy_steps
|
|
479
|
+
data.steps = legacy_steps
|
|
480
|
+
return data
|
|
481
|
+
|
|
482
|
+
def _apply_spec_info(self, legacy_steps, step_specifications, short: bool):
|
|
483
|
+
"""Map the ``info`` column from step specifications onto the step table.
|
|
484
|
+
|
|
485
|
+
Mirrors legacy cellpy: in short mode the spec is keyed by ``step`` only
|
|
486
|
+
(applied across all cycles); otherwise by ``(cycle, step)``. Values are
|
|
487
|
+
copied verbatim (a missing spec ``info`` stays NaN, i.e. ``"nan"``).
|
|
488
|
+
"""
|
|
489
|
+
if "info" not in step_specifications.columns:
|
|
490
|
+
return legacy_steps
|
|
491
|
+
leg = self.step_cols
|
|
492
|
+
spec = step_specifications
|
|
493
|
+
if short:
|
|
494
|
+
info_by_key = dict(zip(spec["step"], spec["info"]))
|
|
495
|
+
legacy_steps[leg.info] = legacy_steps[leg.step].map(info_by_key)
|
|
496
|
+
else:
|
|
497
|
+
info_by_key = {
|
|
498
|
+
(c, s): i
|
|
499
|
+
for c, s, i in zip(spec["cycle"], spec["step"], spec["info"])
|
|
500
|
+
}
|
|
501
|
+
legacy_steps[leg.info] = [
|
|
502
|
+
info_by_key.get((c, s))
|
|
503
|
+
for c, s in zip(legacy_steps[leg.cycle], legacy_steps[leg.step])
|
|
504
|
+
]
|
|
505
|
+
return legacy_steps
|
|
506
|
+
|
|
507
|
+
# ---- legacy <-> native bridge for the polars summary engine -------------
|
|
508
|
+
# The native engine (summarizers.make_summary) produces the clean native
|
|
509
|
+
# CycleCols subset. cellpy expects the full legacy HeadersSummary frame, so
|
|
510
|
+
# this bridge renames native->legacy and computes the legacy-only "extras"
|
|
511
|
+
# (cumulated CE, shifted capacities, RIC, IR, C-rates) that the curated
|
|
512
|
+
# native cycle schema deliberately omits. Those extras reuse the (legacy)
|
|
513
|
+
# pandas helpers, which is appropriate: they are legacy cruft.
|
|
514
|
+
|
|
515
|
+
def _legacy_to_native_step_rename(self) -> dict:
|
|
516
|
+
return header_mapping.legacy_to_native_step()
|
|
517
|
+
|
|
518
|
+
def _native_to_legacy_summary_rename(self) -> dict:
|
|
519
|
+
return header_mapping.native_to_legacy_summary()
|
|
520
|
+
|
|
521
|
+
def _legacy_to_native_summary_rename(self) -> dict:
|
|
522
|
+
return header_mapping.legacy_to_native_summary()
|
|
523
|
+
|
|
524
|
+
def _legacy_summary_column_order(self, find_end_voltage: bool) -> list:
|
|
525
|
+
leg = self.cycle_cols
|
|
526
|
+
order = [
|
|
527
|
+
leg.ir_charge, leg.ir_discharge, leg.data_point, leg.test_time,
|
|
528
|
+
leg.datetime, leg.cycle_index, leg.charge_capacity,
|
|
529
|
+
leg.discharge_capacity, leg.coulombic_efficiency,
|
|
530
|
+
leg.cumulated_coulombic_efficiency, leg.cumulated_charge_capacity,
|
|
531
|
+
leg.cumulated_discharge_capacity, leg.discharge_capacity_loss,
|
|
532
|
+
leg.charge_capacity_loss, leg.coulombic_difference,
|
|
533
|
+
leg.cumulated_coulombic_difference, leg.cumulated_discharge_capacity_loss,
|
|
534
|
+
leg.cumulated_charge_capacity_loss, leg.shifted_charge_capacity,
|
|
535
|
+
leg.shifted_discharge_capacity, leg.cumulated_ric, leg.cumulated_ric_sei,
|
|
536
|
+
leg.cumulated_ric_disconnect,
|
|
537
|
+
]
|
|
538
|
+
if find_end_voltage:
|
|
539
|
+
order += [leg.end_voltage_discharge, leg.end_voltage_charge]
|
|
540
|
+
order += [leg.charge_c_rate, leg.discharge_c_rate]
|
|
541
|
+
return order
|
|
542
|
+
|
|
543
|
+
def _add_legacy_summary_cruft(self, data: Data) -> None:
|
|
544
|
+
"""Add the pandas-only legacy summary columns the native schema omits.
|
|
545
|
+
|
|
546
|
+
``cumulated_coulombic_efficiency``, ``shifted_*`` and ``cumulated_ric*`` are
|
|
547
|
+
legacy cruft with no native ``CycleCols`` equivalent (unlike the C-rates /
|
|
548
|
+
IR, which issue #21 moved onto the native schema). They are computed here in
|
|
549
|
+
pandas, after the native->legacy rename.
|
|
550
|
+
"""
|
|
551
|
+
leg = self.cycle_cols
|
|
552
|
+
s = data.summary
|
|
553
|
+
cc = s[leg.charge_capacity]
|
|
554
|
+
dc = s[leg.discharge_capacity]
|
|
555
|
+
s[leg.cumulated_coulombic_efficiency] = s[leg.coulombic_efficiency].cumsum()
|
|
556
|
+
s[leg.shifted_charge_capacity] = (cc - dc).cumsum()
|
|
557
|
+
s[leg.shifted_discharge_capacity] = s[leg.shifted_charge_capacity] + cc
|
|
558
|
+
s[leg.cumulated_ric] = ((cc.shift(1) - dc) / dc.shift(1)).cumsum()
|
|
559
|
+
s[leg.cumulated_ric_sei] = ((cc - dc.shift(1)) / dc.shift(1)).cumsum()
|
|
560
|
+
s[leg.cumulated_ric_disconnect] = ((dc.shift(1) - dc) / dc.shift(1)).cumsum()
|
|
561
|
+
data.summary = s
|
|
562
|
+
|
|
563
|
+
def make_core_summary(
|
|
564
|
+
self,
|
|
565
|
+
data: Data,
|
|
566
|
+
selector: Optional[Callable] = None,
|
|
567
|
+
find_ir: bool = True,
|
|
568
|
+
find_end_voltage: bool = False,
|
|
569
|
+
select_columns: bool = True,
|
|
570
|
+
final_data_points: Optional[Iterable[int]] = None,
|
|
571
|
+
current_conversion_factor: float = 1.0,
|
|
572
|
+
ir_extractor: Optional[Callable] = None,
|
|
573
|
+
) -> Data:
|
|
574
|
+
"""Build the per-cycle summary via the polars engine, in/out in legacy form.
|
|
575
|
+
|
|
576
|
+
Runs the native ``make_summary`` engine plus the now-native polars C-rate /
|
|
577
|
+
IR helpers, renames native->legacy, then adds the remaining pandas-only
|
|
578
|
+
legacy cruft to reproduce the legacy ``HeadersSummary`` frame.
|
|
579
|
+
|
|
580
|
+
``ir_extractor`` is forwarded to ``summarizers.ir_to_summary`` (defaults to
|
|
581
|
+
``extractors.LastIRExtractor`` when ``None``).
|
|
582
|
+
"""
|
|
583
|
+
import polars as pl
|
|
584
|
+
|
|
585
|
+
from cellpycore import summarizers
|
|
586
|
+
|
|
587
|
+
native_schema = config.default_schema()
|
|
588
|
+
native_raw = pl.from_pandas(
|
|
589
|
+
data.raw.rename(columns=self._legacy_to_native_raw_rename(data.raw.columns))
|
|
590
|
+
)
|
|
591
|
+
native_steps = pl.from_pandas(
|
|
592
|
+
data.steps.rename(columns=self._legacy_to_native_step_rename())
|
|
593
|
+
)
|
|
594
|
+
nd = Data()
|
|
595
|
+
nd.raw = native_raw
|
|
596
|
+
nd.steps = native_steps
|
|
597
|
+
summarizers.make_summary(
|
|
598
|
+
nd, native_schema, final_data_points=final_data_points
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# C-rate / IR are now native-schema columns (issue #21): compute them on the
|
|
602
|
+
# native polars frame before the single native->legacy rename. Their native
|
|
603
|
+
# names match the legacy names, so they survive the rename untouched.
|
|
604
|
+
if find_ir and (native_schema.raw.internal_resistance in nd.raw.columns):
|
|
605
|
+
summarizers.ir_to_summary(nd, native_schema, ir_extractor=ir_extractor)
|
|
606
|
+
summarizers.c_rates_to_summary(
|
|
607
|
+
nd, native_schema, current_conversion_factor=current_conversion_factor
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
leg = self.cycle_cols
|
|
611
|
+
summary = nd.summary.to_pandas().rename(
|
|
612
|
+
columns=self._native_to_legacy_summary_rename()
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# date_time passthrough (native raw carries epoch time, not date_time)
|
|
616
|
+
dp_txt = self.raw_cols.data_point_txt
|
|
617
|
+
dt_txt = self.raw_cols.datetime_txt
|
|
618
|
+
if dt_txt in data.raw.columns:
|
|
619
|
+
# cellpy keeps data_point as both the raw index and a column; drop the
|
|
620
|
+
# index here so the merge key is unambiguous (otherwise pandas raises
|
|
621
|
+
# "'data_point' is both an index level and a column label").
|
|
622
|
+
dt_map = (
|
|
623
|
+
data.raw[[dp_txt, dt_txt]]
|
|
624
|
+
.reset_index(drop=True)
|
|
625
|
+
.drop_duplicates(subset=[dp_txt])
|
|
626
|
+
)
|
|
627
|
+
dt_map = dt_map.rename(columns={dp_txt: leg.data_point})
|
|
628
|
+
summary = summary.merge(dt_map, on=leg.data_point, how="left")
|
|
629
|
+
|
|
630
|
+
summary.index = list(range(len(summary)))
|
|
631
|
+
data.summary = summary
|
|
632
|
+
|
|
633
|
+
self._add_legacy_summary_cruft(data)
|
|
634
|
+
|
|
635
|
+
order = self._legacy_summary_column_order(find_end_voltage)
|
|
636
|
+
order = [c for c in order if c in data.summary.columns]
|
|
637
|
+
data.summary = data.summary[order]
|
|
638
|
+
return data
|
|
639
|
+
|
|
640
|
+
def add_scaled_summary_columns(
|
|
641
|
+
self,
|
|
642
|
+
data: Data,
|
|
643
|
+
nom_cap_abs: float,
|
|
644
|
+
normalization_cycles: Union[Sequence, int, None],
|
|
645
|
+
step_txt: Optional[str] = None,
|
|
646
|
+
specifics: Optional[List[str]] = None,
|
|
647
|
+
specific_converters: Optional[dict] = None,
|
|
648
|
+
) -> Data:
|
|
649
|
+
"""Legacy-bridge ``add_scaled_summary_columns`` (pandas<->polars seam).
|
|
650
|
+
|
|
651
|
+
The native helpers are polars-native on the native schema, but cellpy calls
|
|
652
|
+
this on the legacy pandas summary. So this bridges: legacy pandas summary ->
|
|
653
|
+
native polars -> native ``equivalent_cycles`` / ``generate_specific`` ->
|
|
654
|
+
legacy pandas, mapping the produced specific columns back to legacy names.
|
|
655
|
+
"""
|
|
656
|
+
import polars as pl
|
|
657
|
+
|
|
658
|
+
from cellpycore import summarizers
|
|
659
|
+
from cellpycore.config import default_schema
|
|
660
|
+
|
|
661
|
+
native_schema = default_schema()
|
|
662
|
+
if specifics is None:
|
|
663
|
+
specifics = ["gravimetric", "areal", "absolute"]
|
|
664
|
+
|
|
665
|
+
nd = Data()
|
|
666
|
+
nd.summary = pl.from_pandas(
|
|
667
|
+
data.summary.rename(columns=self._legacy_to_native_summary_rename())
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
if step_txt is None:
|
|
671
|
+
step_txt = (
|
|
672
|
+
native_schema.cycle.discharge_capacity
|
|
673
|
+
if self.cycle_mode == "anode"
|
|
674
|
+
else native_schema.cycle.charge_capacity
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
summarizers.equivalent_cycles_to_summary(
|
|
678
|
+
nd, native_schema, nom_cap_abs, normalization_cycles, step_txt
|
|
679
|
+
)
|
|
680
|
+
specific_columns = native_schema.cycle.specific_columns
|
|
681
|
+
for mode in specifics:
|
|
682
|
+
converter = self._resolve_specific_converter(data, mode, specific_converters)
|
|
683
|
+
summarizers.generate_specific_summary_columns(
|
|
684
|
+
nd, mode, specific_columns, converter
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# native -> legacy, incl. the generated ``{col}_{mode}`` specific columns.
|
|
688
|
+
rename = self._native_to_legacy_summary_rename()
|
|
689
|
+
for col in specific_columns:
|
|
690
|
+
legacy_col = rename.get(col, col)
|
|
691
|
+
for mode in specifics:
|
|
692
|
+
rename[f"{col}_{mode}"] = f"{legacy_col}_{mode}"
|
|
693
|
+
result = nd.summary.to_pandas().rename(columns=rename)
|
|
694
|
+
result.index = list(range(len(result)))
|
|
695
|
+
data.summary = result
|
|
696
|
+
return data
|