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,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