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 ADDED
File without changes
@@ -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