ici-analysis 2.1.0__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.
- analysis/__init__.py +0 -0
- analysis/data_loader.py +407 -0
- analysis/kinetic_analyzer.py +577 -0
- analysis/phase_classifier.py +190 -0
- analysis/pulse_analyzer.py +152 -0
- analysis/regression_analyzer.py +435 -0
- gui/__init__.py +0 -0
- gui/components/__init__.py +0 -0
- gui/components/export_helper.py +32 -0
- gui/components/plot_panel.py +111 -0
- gui/components/scroll.py +42 -0
- gui/components/ui_column_map.py +145 -0
- gui/sections/__init__.py +7 -0
- gui/sections/column_map.py +145 -0
- gui/sections/data_loading.py +736 -0
- gui/sections/kinetics.py +684 -0
- gui/sections/pulse.py +643 -0
- gui/sections/regression.py +1001 -0
- gui/theme.py +128 -0
- ici_analysis-2.1.0.dist-info/METADATA +217 -0
- ici_analysis-2.1.0.dist-info/RECORD +27 -0
- ici_analysis-2.1.0.dist-info/WHEEL +5 -0
- ici_analysis-2.1.0.dist-info/entry_points.txt +2 -0
- ici_analysis-2.1.0.dist-info/licenses/LICENSE +21 -0
- ici_analysis-2.1.0.dist-info/top_level.txt +4 -0
- main_gui.py +171 -0
- pyici/__init__.py +75 -0
analysis/__init__.py
ADDED
|
File without changes
|
analysis/data_loader.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ICI Battery Analysis - Data Loader Module
|
|
4
|
+
Multi-cycle ICI data loading and processing with smart auto-detection.
|
|
5
|
+
Supports single-cycle (3 columns) and multi-cycle (4 columns) formats.
|
|
6
|
+
Pure analysis layer: run_data_analysis takes inputs as arguments and returns
|
|
7
|
+
a DataLoadResult (no module-level data globals).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import numpy as np
|
|
13
|
+
from collections import namedtuple
|
|
14
|
+
|
|
15
|
+
# Re-exported so every analysis module exposes the SAME constant object (F3
|
|
16
|
+
# single-source guard in tests/test_pulse_analysis.py). Imported, not used here.
|
|
17
|
+
from analysis.phase_classifier import MAX_REST_DURATION # noqa: F401
|
|
18
|
+
|
|
19
|
+
# =============================================================================
|
|
20
|
+
# CONFIGURATION (defaults; the GUI passes its own values as arguments)
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
CURRENT_THRESHOLD = 1.0 # mA
|
|
24
|
+
|
|
25
|
+
# Cycle identification for files WITHOUT a cycle-number column:
|
|
26
|
+
# 'auto' -> detect cycles automatically from the current signal
|
|
27
|
+
# 'file' -> treat the whole file as a single cycle
|
|
28
|
+
CYCLE_DETECTION_MODE = 'auto'
|
|
29
|
+
|
|
30
|
+
# Standard column names (internal format after processing)
|
|
31
|
+
STANDARD_COLUMNS = ['cycle', 't/s', 'E/V', 'I/mA']
|
|
32
|
+
|
|
33
|
+
# =============================================================================
|
|
34
|
+
# RESULT TYPE
|
|
35
|
+
# =============================================================================
|
|
36
|
+
|
|
37
|
+
DataLoadResult = namedtuple(
|
|
38
|
+
"DataLoadResult",
|
|
39
|
+
["df_raw", "plot_data", "ici_starts", "cycle_list", "data_format"],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# DATA LOADING HELPER FUNCTIONS
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
# Data files can be saved in different text encodings. We try the most likely
|
|
47
|
+
# ones in order and keep the first that reads the file without errors: UTF-8
|
|
48
|
+
# (today's standard) first, then the older Windows encodings some cyclers still
|
|
49
|
+
# use. latin-1 is last because it can read any file, so a file never fails to
|
|
50
|
+
# open just because of its encoding.
|
|
51
|
+
_ENCODINGS = ('utf-8-sig', 'utf-8', 'cp1252', 'latin-1')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _detect_encoding(file_path):
|
|
55
|
+
"""Return the first encoding from ``_ENCODINGS`` that reads the whole file
|
|
56
|
+
without an error.
|
|
57
|
+
|
|
58
|
+
Data files are usually UTF-8, but some cyclers save them in a Windows
|
|
59
|
+
encoding; assuming UTF-8 would make those fail to open. latin-1 reads any
|
|
60
|
+
file, so this always returns a usable encoding.
|
|
61
|
+
"""
|
|
62
|
+
for enc in _ENCODINGS:
|
|
63
|
+
try:
|
|
64
|
+
with open(file_path, 'r', encoding=enc) as f:
|
|
65
|
+
f.read()
|
|
66
|
+
return enc
|
|
67
|
+
except (UnicodeDecodeError, UnicodeError):
|
|
68
|
+
continue
|
|
69
|
+
return 'latin-1'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def inspect_data_file(file_path):
|
|
73
|
+
"""Inspect the data file to determine its delimiter and text encoding.
|
|
74
|
+
|
|
75
|
+
Returns (delimiter, first_lines, encoding) or (None, [], 'utf-8') on failure.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
encoding = _detect_encoding(file_path)
|
|
79
|
+
with open(file_path, 'r', encoding=encoding) as f:
|
|
80
|
+
lines = [f.readline().strip() for _ in range(10)]
|
|
81
|
+
|
|
82
|
+
first_data_line = lines[1] if len(lines) > 1 else lines[0]
|
|
83
|
+
if '\t' in first_data_line:
|
|
84
|
+
delimiter = '\t'
|
|
85
|
+
elif ',' in first_data_line:
|
|
86
|
+
delimiter = ','
|
|
87
|
+
else:
|
|
88
|
+
delimiter = None
|
|
89
|
+
|
|
90
|
+
return delimiter, lines, encoding
|
|
91
|
+
|
|
92
|
+
except Exception:
|
|
93
|
+
return None, [], 'utf-8'
|
|
94
|
+
|
|
95
|
+
def _is_headerless(file_path, delimiter, encoding='utf-8'):
|
|
96
|
+
"""Return True when the file's first row is entirely numeric, i.e. there is
|
|
97
|
+
no header row (the first line is already data)."""
|
|
98
|
+
try:
|
|
99
|
+
with open(file_path, 'r', encoding=encoding) as f:
|
|
100
|
+
first = f.readline().strip()
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
fields = [x for x in first.split(delimiter) if x.strip() != '']
|
|
104
|
+
if not fields:
|
|
105
|
+
return False
|
|
106
|
+
for x in fields:
|
|
107
|
+
try:
|
|
108
|
+
float(x)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return False # a non-numeric token -> this row is a header
|
|
111
|
+
return True # all tokens numeric -> no header row
|
|
112
|
+
|
|
113
|
+
def _read_table(file_path, delimiter, encoding='utf-8'):
|
|
114
|
+
"""Read a delimited file, auto-handling a missing header row.
|
|
115
|
+
|
|
116
|
+
If the first row is all-numeric the file is treated as header-less: every
|
|
117
|
+
row is kept as data and generic column names (col1, col2, ...) are assigned
|
|
118
|
+
so nothing is lost to being mistaken for a header."""
|
|
119
|
+
if _is_headerless(file_path, delimiter, encoding):
|
|
120
|
+
df = pd.read_csv(file_path, delimiter=delimiter, encoding=encoding, header=None)
|
|
121
|
+
df.columns = [f'col{i + 1}' for i in range(df.shape[1])]
|
|
122
|
+
else:
|
|
123
|
+
df = pd.read_csv(file_path, delimiter=delimiter, encoding=encoding)
|
|
124
|
+
return df
|
|
125
|
+
|
|
126
|
+
def load_data_flexible(file_path):
|
|
127
|
+
"""Load data with flexible format detection. Returns a DataFrame or None."""
|
|
128
|
+
delimiter, sample_lines, encoding = inspect_data_file(file_path)
|
|
129
|
+
|
|
130
|
+
if delimiter is None:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
return _read_table(file_path, delimiter, encoding)
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def detect_data_format(df):
|
|
139
|
+
"""Detect if data is single-cycle or multi-cycle format based on column count.
|
|
140
|
+
|
|
141
|
+
Returns (data_format, cleaned_df) where data_format is
|
|
142
|
+
'single_cycle', 'multi_cycle' or 'unknown'.
|
|
143
|
+
"""
|
|
144
|
+
df_clean = df.copy()
|
|
145
|
+
|
|
146
|
+
columns_to_drop = []
|
|
147
|
+
for col in df_clean.columns:
|
|
148
|
+
if 'unnamed' in col.lower() or df_clean[col].isna().all():
|
|
149
|
+
columns_to_drop.append(col)
|
|
150
|
+
|
|
151
|
+
if columns_to_drop:
|
|
152
|
+
df_clean = df_clean.drop(columns=columns_to_drop)
|
|
153
|
+
|
|
154
|
+
num_cols = len(df_clean.columns)
|
|
155
|
+
|
|
156
|
+
if num_cols == 3:
|
|
157
|
+
data_format = "single_cycle"
|
|
158
|
+
elif num_cols == 4:
|
|
159
|
+
data_format = "multi_cycle"
|
|
160
|
+
else:
|
|
161
|
+
data_format = "unknown"
|
|
162
|
+
|
|
163
|
+
return data_format, df_clean
|
|
164
|
+
|
|
165
|
+
def standardize_columns_by_position(df, data_format):
|
|
166
|
+
"""Standardize column names based on position rather than names.
|
|
167
|
+
|
|
168
|
+
Returns the standardized DataFrame, or None if the column count does not
|
|
169
|
+
match the format.
|
|
170
|
+
"""
|
|
171
|
+
df_std = df.copy()
|
|
172
|
+
|
|
173
|
+
if data_format == "single_cycle":
|
|
174
|
+
if len(df_std.columns) != 3:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
old_cols = list(df_std.columns)
|
|
178
|
+
new_column_mapping = {
|
|
179
|
+
old_cols[0]: 't/s',
|
|
180
|
+
old_cols[1]: 'E/V',
|
|
181
|
+
old_cols[2]: 'I/mA'
|
|
182
|
+
}
|
|
183
|
+
df_std = df_std.rename(columns=new_column_mapping)
|
|
184
|
+
df_std.insert(0, 'cycle', 1)
|
|
185
|
+
|
|
186
|
+
elif data_format == "multi_cycle":
|
|
187
|
+
if len(df_std.columns) != 4:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
old_cols = list(df_std.columns)
|
|
191
|
+
new_column_mapping = {
|
|
192
|
+
old_cols[0]: 'cycle',
|
|
193
|
+
old_cols[1]: 't/s',
|
|
194
|
+
old_cols[2]: 'E/V',
|
|
195
|
+
old_cols[3]: 'I/mA'
|
|
196
|
+
}
|
|
197
|
+
df_std = df_std.rename(columns=new_column_mapping)
|
|
198
|
+
|
|
199
|
+
else:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
required_cols = STANDARD_COLUMNS
|
|
203
|
+
missing_cols = [col for col in required_cols if col not in df_std.columns]
|
|
204
|
+
|
|
205
|
+
if missing_cols:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
return df_std
|
|
209
|
+
|
|
210
|
+
def peek_columns(file_path):
|
|
211
|
+
"""Return the usable column names of a data file (after dropping unnamed /
|
|
212
|
+
all-NaN columns), or [] on failure. Used by the GUI column-mapping dialog."""
|
|
213
|
+
delimiter, _, encoding = inspect_data_file(file_path)
|
|
214
|
+
if delimiter is None:
|
|
215
|
+
return []
|
|
216
|
+
try:
|
|
217
|
+
df = _read_table(file_path, delimiter, encoding)
|
|
218
|
+
except Exception:
|
|
219
|
+
return []
|
|
220
|
+
drop = [c for c in df.columns
|
|
221
|
+
if 'unnamed' in str(c).lower() or df[c].isna().all()]
|
|
222
|
+
if drop:
|
|
223
|
+
df = df.drop(columns=drop)
|
|
224
|
+
return list(df.columns)
|
|
225
|
+
|
|
226
|
+
def build_df_from_map(df, col_map):
|
|
227
|
+
"""Build the standard [cycle, t/s, E/V, I/mA] DataFrame from a manual column
|
|
228
|
+
mapping supplied by the GUI.
|
|
229
|
+
|
|
230
|
+
col_map keys: 'time', 'voltage', 'current', 'cycle'. 'cycle' may be None,
|
|
231
|
+
in which case a single cycle is created and cycles are detected downstream
|
|
232
|
+
from the current signal. Any column not referenced is ignored.
|
|
233
|
+
"""
|
|
234
|
+
out = pd.DataFrame()
|
|
235
|
+
out['t/s'] = pd.to_numeric(df[col_map['time']], errors='coerce')
|
|
236
|
+
out['E/V'] = pd.to_numeric(df[col_map['voltage']], errors='coerce')
|
|
237
|
+
out['I/mA'] = pd.to_numeric(df[col_map['current']], errors='coerce')
|
|
238
|
+
if col_map.get('cycle'):
|
|
239
|
+
out.insert(0, 'cycle', pd.to_numeric(df[col_map['cycle']], errors='coerce'))
|
|
240
|
+
else:
|
|
241
|
+
out.insert(0, 'cycle', 1)
|
|
242
|
+
return out[['cycle', 't/s', 'E/V', 'I/mA']]
|
|
243
|
+
|
|
244
|
+
# =============================================================================
|
|
245
|
+
# CORE ANALYSIS FUNCTIONS
|
|
246
|
+
# =============================================================================
|
|
247
|
+
|
|
248
|
+
def detect_cycles_from_current(df, current_threshold=1.0, current_col='I/mA'):
|
|
249
|
+
"""Signal-based, start-agnostic cycle detection.
|
|
250
|
+
|
|
251
|
+
A full cycle = two half-cycles (one charge + one discharge, in whichever
|
|
252
|
+
order they occur). Current interruptions and CV-taper points sit at/near
|
|
253
|
+
zero, count as 'rest', and are absorbed into the surrounding half-cycle so
|
|
254
|
+
they never create a false boundary.
|
|
255
|
+
"""
|
|
256
|
+
current = df[current_col].to_numpy(dtype=float)
|
|
257
|
+
n = len(current)
|
|
258
|
+
if n == 0:
|
|
259
|
+
return np.ones(0, dtype=int)
|
|
260
|
+
|
|
261
|
+
phase = np.where(current > current_threshold, 1,
|
|
262
|
+
np.where(current < -current_threshold, -1, 0)).astype(int)
|
|
263
|
+
|
|
264
|
+
nz = np.flatnonzero(phase != 0)
|
|
265
|
+
if nz.size == 0:
|
|
266
|
+
# No current above threshold - treat as a single cycle
|
|
267
|
+
return np.ones(n, dtype=int)
|
|
268
|
+
|
|
269
|
+
ff = np.zeros(n, dtype=int)
|
|
270
|
+
ff[nz] = nz
|
|
271
|
+
ff = np.maximum.accumulate(ff)
|
|
272
|
+
filled = phase[ff]
|
|
273
|
+
filled[:nz[0]] = phase[nz[0]]
|
|
274
|
+
|
|
275
|
+
changed = np.zeros(n, dtype=bool)
|
|
276
|
+
changed[1:] = filled[1:] != filled[:-1]
|
|
277
|
+
half_index = np.cumsum(changed)
|
|
278
|
+
|
|
279
|
+
cycles = (half_index // 2) + 1
|
|
280
|
+
return cycles.astype(int)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def detect_and_fix_cycle_structure(df, current_threshold=1.0,
|
|
284
|
+
mode='auto', data_format=None):
|
|
285
|
+
"""Process cycle structure.
|
|
286
|
+
|
|
287
|
+
For 3-column (single-cycle format) files, cycles can be auto-detected from
|
|
288
|
+
the current signal when mode == 'auto'. 4-column files keep their own
|
|
289
|
+
cycle-number column unchanged.
|
|
290
|
+
"""
|
|
291
|
+
df_fixed = df.copy()
|
|
292
|
+
|
|
293
|
+
if 'cycle' not in df_fixed.columns:
|
|
294
|
+
return df_fixed
|
|
295
|
+
|
|
296
|
+
# Remove rows with a NaN cycle number
|
|
297
|
+
df_fixed = df_fixed.dropna(subset=['cycle'])
|
|
298
|
+
|
|
299
|
+
if len(df_fixed) == 0:
|
|
300
|
+
return df_fixed
|
|
301
|
+
|
|
302
|
+
if data_format == 'single_cycle' and mode == 'auto':
|
|
303
|
+
df_fixed['cycle'] = detect_cycles_from_current(
|
|
304
|
+
df_fixed, current_threshold=current_threshold).astype(int)
|
|
305
|
+
|
|
306
|
+
return df_fixed
|
|
307
|
+
|
|
308
|
+
def find_ici_starts(df, current_threshold=1.0):
|
|
309
|
+
"""Find ICI start points (first sample above the current threshold) per cycle.
|
|
310
|
+
|
|
311
|
+
Returns {cycle_num: row_index}.
|
|
312
|
+
"""
|
|
313
|
+
ici_starts = {}
|
|
314
|
+
|
|
315
|
+
if len(df) == 0:
|
|
316
|
+
return ici_starts
|
|
317
|
+
|
|
318
|
+
for cycle_num in sorted(df['cycle'].unique()):
|
|
319
|
+
cycle_data = df[df['cycle'] == cycle_num]
|
|
320
|
+
|
|
321
|
+
charge_start_idx = None
|
|
322
|
+
for idx, row in cycle_data.iterrows():
|
|
323
|
+
if row['I/mA'] > current_threshold:
|
|
324
|
+
charge_start_idx = idx
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
if charge_start_idx is not None:
|
|
328
|
+
ici_starts[cycle_num] = charge_start_idx
|
|
329
|
+
|
|
330
|
+
return ici_starts
|
|
331
|
+
|
|
332
|
+
def downsample_data(data, max_points=10000, target_points=5000):
|
|
333
|
+
"""Keep all data points - no downsampling for complete analysis."""
|
|
334
|
+
return data
|
|
335
|
+
|
|
336
|
+
# =============================================================================
|
|
337
|
+
# MAIN EXECUTION FUNCTION
|
|
338
|
+
# =============================================================================
|
|
339
|
+
|
|
340
|
+
def run_data_analysis(folder_path, txt_file_name, *,
|
|
341
|
+
current_threshold=CURRENT_THRESHOLD,
|
|
342
|
+
cycle_detection_mode=CYCLE_DETECTION_MODE,
|
|
343
|
+
column_map=None):
|
|
344
|
+
"""Load and process an ICI data file into the standard
|
|
345
|
+
[cycle, t/s, E/V, I/mA] form.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
folder_path, txt_file_name : location of the data file.
|
|
350
|
+
current_threshold : mA threshold for signal-based cycle detection / ICI starts.
|
|
351
|
+
cycle_detection_mode : 'auto' or 'file' (only affects 3-column files).
|
|
352
|
+
column_map : optional dict {'time','voltage','current','cycle'} from the GUI
|
|
353
|
+
mapping dialog for files with more than 3 columns ('cycle' may be None).
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
DataLoadResult(df_raw, plot_data, ici_starts, cycle_list, data_format), or
|
|
358
|
+
None on failure.
|
|
359
|
+
"""
|
|
360
|
+
if not folder_path or not txt_file_name:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
txt_path = os.path.join(folder_path, txt_file_name)
|
|
365
|
+
if not os.path.exists(txt_path):
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
df_loaded = load_data_flexible(txt_path)
|
|
369
|
+
if df_loaded is None:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
if column_map is not None:
|
|
373
|
+
_, df_cleaned = detect_data_format(df_loaded) # reuse the cleaning step
|
|
374
|
+
df_raw = build_df_from_map(df_cleaned, column_map)
|
|
375
|
+
data_format = 'multi_cycle' if column_map.get('cycle') else 'single_cycle'
|
|
376
|
+
else:
|
|
377
|
+
data_format, df_cleaned = detect_data_format(df_loaded)
|
|
378
|
+
if data_format == "unknown":
|
|
379
|
+
return None
|
|
380
|
+
df_raw = standardize_columns_by_position(df_cleaned, data_format)
|
|
381
|
+
|
|
382
|
+
if df_raw is None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
df_raw = detect_and_fix_cycle_structure(
|
|
386
|
+
df_raw, current_threshold,
|
|
387
|
+
mode=cycle_detection_mode, data_format=data_format)
|
|
388
|
+
if len(df_raw) == 0:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
ici_starts = find_ici_starts(df_raw, current_threshold)
|
|
392
|
+
|
|
393
|
+
cycle_list = sorted(df_raw['cycle'].unique())
|
|
394
|
+
if len(cycle_list) == 0:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
plot_data = downsample_data(df_raw, max_points=10000, target_points=5000)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
cycle_list = [int(c) for c in cycle_list if not pd.isna(c)]
|
|
401
|
+
except Exception:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
return DataLoadResult(df_raw, plot_data, ici_starts, cycle_list, data_format)
|
|
405
|
+
|
|
406
|
+
except Exception:
|
|
407
|
+
return None
|