batplot 1.8.1__py3-none-any.whl → 1.8.2__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.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (38) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +10 -0
  5. batplot/interactive.py +10 -0
  6. batplot/modes.py +12 -12
  7. batplot/operando_ec_interactive.py +4 -4
  8. batplot/session.py +17 -0
  9. batplot/version_check.py +1 -1
  10. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  11. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
  12. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  13. batplot_backup_20251221_101150/__init__.py +5 -0
  14. batplot_backup_20251221_101150/args.py +625 -0
  15. batplot_backup_20251221_101150/batch.py +1176 -0
  16. batplot_backup_20251221_101150/batplot.py +3589 -0
  17. batplot_backup_20251221_101150/cif.py +823 -0
  18. batplot_backup_20251221_101150/cli.py +149 -0
  19. batplot_backup_20251221_101150/color_utils.py +547 -0
  20. batplot_backup_20251221_101150/config.py +198 -0
  21. batplot_backup_20251221_101150/converters.py +204 -0
  22. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  23. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  24. batplot_backup_20251221_101150/interactive.py +3894 -0
  25. batplot_backup_20251221_101150/manual.py +323 -0
  26. batplot_backup_20251221_101150/modes.py +799 -0
  27. batplot_backup_20251221_101150/operando.py +603 -0
  28. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  29. batplot_backup_20251221_101150/plotting.py +228 -0
  30. batplot_backup_20251221_101150/readers.py +2607 -0
  31. batplot_backup_20251221_101150/session.py +2951 -0
  32. batplot_backup_20251221_101150/style.py +1441 -0
  33. batplot_backup_20251221_101150/ui.py +790 -0
  34. batplot_backup_20251221_101150/utils.py +1046 -0
  35. batplot_backup_20251221_101150/version_check.py +253 -0
  36. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  37. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  38. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,799 @@
1
+ """Mode handlers for different batplot plotting modes.
2
+
3
+ This module implements the core plotting logic for each supported mode:
4
+
5
+ Supported Modes:
6
+ - CV (Cyclic Voltammetry): voltage vs current curves by cycle
7
+ - GC (Galvanostatic Cycling): capacity vs voltage curves by cycle
8
+ - dQ/dV: differential capacity analysis
9
+ - CPC (Capacity-per-Cycle): capacity and efficiency vs cycle number
10
+ - Operando: combined XRD/electrochemistry contour plots
11
+
12
+ Architecture:
13
+ Each mode has a handle_*_mode() function that:
14
+ 1. Validates input files and arguments
15
+ 2. Reads and processes data using readers.py
16
+ 3. Creates matplotlib figure with appropriate styling
17
+ 4. Optionally launches interactive menu for customization
18
+ 5. Saves figure if requested
19
+ 6. Returns exit code (0=success, 1=error)
20
+
21
+ Design Principles:
22
+ - Mode handlers are independent and can be called directly
23
+ - All imports happen at module level (extracted from batplot.py)
24
+ - Interactive menus are optional (graceful degradation)
25
+ - Consistent styling across all modes
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import sys
32
+ from typing import Dict, Any, Optional
33
+
34
+ import numpy as np
35
+ import matplotlib.pyplot as plt
36
+
37
+ from .readers import read_mpt_file, read_ec_csv_file, read_ec_csv_dqdv_file, read_biologic_txt_file
38
+ from .electrochem_interactive import electrochem_interactive_menu
39
+
40
+ # Try to import optional interactive menus
41
+ # These may not be available if dependencies are missing
42
+ try:
43
+ from .operando_ec_interactive import operando_ec_interactive_menu
44
+ except ImportError:
45
+ operando_ec_interactive_menu = None
46
+
47
+ try:
48
+ from .cpc_interactive import cpc_interactive_menu
49
+ except ImportError:
50
+ cpc_interactive_menu = None
51
+
52
+
53
+ def handle_cv_mode(args) -> int:
54
+ """Handle cyclic voltammetry (CV) plotting mode.
55
+
56
+ Cyclic voltammetry plots show current vs. voltage curves. Each cycle is
57
+ a complete voltage sweep (forward and reverse). This is useful for
58
+ studying redox reactions, electrode kinetics, and electrochemical windows.
59
+
60
+ Data Flow:
61
+ 1. Validate input (single .mpt or .txt file required)
62
+ 2. Read voltage, current, cycle data from file
63
+ 3. Normalize cycle numbers to start at 1
64
+ 4. Plot each cycle with unique color
65
+ 5. Handle discontinuities in cycle data (insert NaNs for breaks)
66
+ 6. Launch interactive menu or save/show figure
67
+
68
+ Args:
69
+ args: Argument namespace containing:
70
+ - files: List with single file path (.mpt or .txt)
71
+ - interactive: bool, whether to launch interactive customization menu
72
+ - savefig/out: optional output filename
73
+
74
+ Returns:
75
+ Exit code: 0 for success, 1 for error
76
+
77
+ File Format Requirements:
78
+ - .mpt: BioLogic native format with CV mode data
79
+ - .txt: BioLogic exported text format
80
+ - Must contain voltage, current, and cycle index columns
81
+ """
82
+ # === INPUT VALIDATION ===
83
+ if len(args.files) != 1:
84
+ print("CV mode: provide exactly one file (.mpt or .txt).")
85
+ return 1
86
+
87
+ ec_file = args.files[0]
88
+ if not os.path.isfile(ec_file):
89
+ print(f"File not found: {ec_file}")
90
+ return 1
91
+
92
+ try:
93
+ # === DATA LOADING ===
94
+ # Support both .mpt (native) and .txt (exported) formats
95
+ if ec_file.lower().endswith('.txt'):
96
+ voltage, current, cycles = read_biologic_txt_file(ec_file, mode='cv')
97
+ else:
98
+ voltage, current, cycles = read_mpt_file(ec_file, mode='cv')
99
+
100
+ # ====================================================================
101
+ # CYCLE NORMALIZATION
102
+ # ====================================================================
103
+ # Different cycler software may number cycles differently:
104
+ # - Some start at 0 (Cycle 0, Cycle 1, Cycle 2, ...)
105
+ # - Some start at 1 (Cycle 1, Cycle 2, Cycle 3, ...)
106
+ # - Some use negative numbers or other schemes
107
+ #
108
+ # We normalize all cycles to start at 1 for consistency and user-friendliness.
109
+ # This ensures Cycle 1 always means "the first cycle" regardless of file format.
110
+ #
111
+ # HOW IT WORKS:
112
+ # 1. Round cycle numbers to integers (they might be floats from file)
113
+ # 2. Find the minimum cycle number
114
+ # 3. Calculate shift needed to make minimum = 1
115
+ # 4. Apply shift to all cycles
116
+ #
117
+ # Example:
118
+ # File has cycles: [0, 0, 0, 1, 1, 1, 2, 2, 2]
119
+ # min_c = 0
120
+ # shift = 1 - 0 = 1
121
+ # Result: [1, 1, 1, 2, 2, 2, 3, 3, 3]
122
+ # ====================================================================
123
+
124
+ # Convert cycle numbers to integers (round to nearest integer first)
125
+ # Some files might have fractional cycle numbers due to data processing
126
+ cyc_int_raw = np.array(np.rint(cycles), dtype=int)
127
+
128
+ # Find the minimum cycle number in the data
129
+ if cyc_int_raw.size:
130
+ min_c = int(np.min(cyc_int_raw))
131
+ else:
132
+ min_c = 1 # Default if no data
133
+
134
+ # Calculate shift needed to make cycles start at 1
135
+ # If min_c is 0 or negative, we need to shift up
136
+ # If min_c is already 1 or greater, no shift needed
137
+ shift = 1 - min_c if min_c <= 0 else 0
138
+
139
+ # Apply shift to all cycles
140
+ cyc_int = cyc_int_raw + shift
141
+
142
+ # Get sorted list of unique cycle numbers present in data
143
+ # This tells us which cycles we need to plot (e.g., [1, 2, 3, 5, 7] if cycles 4 and 6 are missing)
144
+ cycles_present = sorted(int(c) for c in np.unique(cyc_int)) if cyc_int.size else [1]
145
+
146
+ # === STYLING SETUP ===
147
+ # Use matplotlib's default color cycle (Tab10 colormap)
148
+ base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
149
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
150
+
151
+ # Configure fonts to match other modes (consistent across batplot)
152
+ plt.rcParams.update({
153
+ 'font.family': 'sans-serif',
154
+ 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
155
+ 'mathtext.fontset': 'dejavusans',
156
+ 'font.size': 16
157
+ })
158
+
159
+ # === FIGURE CREATION ===
160
+ fig, ax = plt.subplots(figsize=(10, 6))
161
+ cycle_lines = {} # Store line objects for interactive menu
162
+
163
+ # ====================================================================
164
+ # PLOTTING EACH CYCLE
165
+ # ====================================================================
166
+ # Loop through each cycle and plot it as a separate line.
167
+ # Each cycle gets a different color from the base_colors list.
168
+ #
169
+ # HOW CYCLES ARE READ:
170
+ # --------------------
171
+ # The data file contains voltage and current measurements, with each
172
+ # measurement point labeled with a cycle number. We group points by
173
+ # cycle number and plot each group as a separate line.
174
+ #
175
+ # Example data structure:
176
+ # voltage = [3.0, 3.1, 3.2, 2.9, 2.8, 2.7, 3.0, 3.1, 3.2, ...]
177
+ # current = [0.1, 0.2, 0.3, -0.1, -0.2, -0.3, 0.1, 0.2, 0.3, ...]
178
+ # cycles = [1, 1, 1, 2, 2, 2, 3, 3, 3, ...]
179
+ #
180
+ # This means:
181
+ # - Points 0-2 belong to Cycle 1 (voltage increasing = charge)
182
+ # - Points 3-5 belong to Cycle 2 (voltage decreasing = discharge)
183
+ # - Points 6-8 belong to Cycle 3 (voltage increasing = charge)
184
+ #
185
+ # We plot each cycle as a separate line with a different color.
186
+ # ====================================================================
187
+
188
+ for cyc in cycles_present:
189
+ # STEP 1: Find all data points that belong to this cycle
190
+ # Create a boolean mask: True where cycle number matches current cycle
191
+ mask = (cyc_int == cyc)
192
+ # Get indices where mask is True (these are the data points for this cycle)
193
+ idx = np.where(mask)[0]
194
+
195
+ # Need at least 2 points to draw a line
196
+ if idx.size >= 2:
197
+ # ============================================================
198
+ # HANDLE DISCONTINUITIES (Gaps in Data)
199
+ # ============================================================
200
+ # Sometimes experiments are paused, or data is recorded in segments.
201
+ # This creates gaps in the data where consecutive indices are not
202
+ # sequential (e.g., indices [10, 11, 12, 50, 51, 52] has a gap).
203
+ #
204
+ # Problem: If we plot all points as one line, matplotlib will draw
205
+ # a line connecting the gap (which looks wrong).
206
+ #
207
+ # Solution: Split into continuous segments and insert NaN between them.
208
+ # Matplotlib treats NaN as a break in the line, so it won't
209
+ # draw across the gap.
210
+ #
211
+ # Example:
212
+ # Original indices: [10, 11, 12, 50, 51, 52]
213
+ # Segments found: [10-12] and [50-52]
214
+ # After NaN insertion: [10, 11, 12, NaN, 50, 51, 52]
215
+ # Result: Two separate line segments, no line across the gap
216
+ # ============================================================
217
+
218
+ # Find all continuous segments (runs of consecutive indices)
219
+ parts_x = [] # Will store voltage arrays for each segment
220
+ parts_y = [] # Will store current arrays for each segment
221
+ start = 0 # Start index of current segment
222
+
223
+ # Scan through indices looking for gaps
224
+ for k in range(1, idx.size):
225
+ # If current index is not consecutive with previous, we found a gap
226
+ if idx[k] != idx[k-1] + 1: # Gap detected
227
+ # Save the segment we just finished (from start to k-1)
228
+ parts_x.append(voltage[idx[start:k]])
229
+ parts_y.append(current[idx[start:k]])
230
+ # Start tracking a new segment
231
+ start = k
232
+
233
+ # Don't forget the last segment (after the loop ends)
234
+ parts_x.append(voltage[idx[start:]])
235
+ parts_y.append(current[idx[start:]])
236
+
237
+ # STEP 2: Concatenate segments with NaN separators
238
+ # This creates one array per axis, with NaN values marking segment breaks
239
+ X = [] # Will contain all voltage segments with NaN separators
240
+ Y = [] # Will contain all current segments with NaN separators
241
+
242
+ for i, (px, py) in enumerate(zip(parts_x, parts_y)):
243
+ if i > 0:
244
+ # Insert NaN between segments (except before the first segment)
245
+ # This tells matplotlib to break the line here
246
+ X.append(np.array([np.nan]))
247
+ Y.append(np.array([np.nan]))
248
+ # Add the segment data
249
+ X.append(px)
250
+ Y.append(py)
251
+
252
+ # Concatenate all segments into single arrays for plotting
253
+ x_b = np.concatenate(X) if X else np.array([])
254
+ y_b = np.concatenate(Y) if Y else np.array([])
255
+
256
+ # STEP 3: Plot this cycle with a unique color
257
+ # Color selection: Cycle through base_colors list, wrapping around if needed
258
+ # Example: Cycle 1 → color[0], Cycle 2 → color[1], ..., Cycle 11 → color[0] (wrapped)
259
+ # The modulo operator (%) ensures we wrap around: (cyc-1) % 10 gives 0-9
260
+ # Swap x and y if --ro flag is set
261
+ if getattr(args, 'ro', False):
262
+ ln, = ax.plot(y_b, x_b, '-', # '-' = solid line style
263
+ color=base_colors[(cyc-1) % len(base_colors)], # Cycle through colors
264
+ linewidth=2.0, # Line thickness
265
+ label=str(cyc), # Cycle number for legend
266
+ alpha=0.8) # Slight transparency (80% opaque)
267
+ else:
268
+ ln, = ax.plot(x_b, y_b, '-', # '-' = solid line style
269
+ color=base_colors[(cyc-1) % len(base_colors)], # Cycle through colors
270
+ linewidth=2.0, # Line thickness
271
+ label=str(cyc), # Cycle number for legend
272
+ alpha=0.8) # Slight transparency (80% opaque)
273
+
274
+ # Store line object for interactive menu (allows changing color later)
275
+ cycle_lines[cyc] = ln
276
+
277
+ # === FINAL STYLING ===
278
+ # Swap axis labels if --ro flag is set
279
+ if getattr(args, 'ro', False):
280
+ ax.set_xlabel('Current (mA)', labelpad=8.0)
281
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
282
+ else:
283
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
284
+ ax.set_ylabel('Current (mA)', labelpad=8.0)
285
+ legend = ax.legend(title='Cycle')
286
+ legend.get_title().set_fontsize('medium')
287
+ # Adjust margins to prevent label clipping
288
+ fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
289
+
290
+ # === SAVE FIGURE (if requested) ===
291
+ outname = args.savefig or args.out
292
+ if outname:
293
+ # Default to SVG if no extension provided
294
+ if not os.path.splitext(outname)[1]:
295
+ outname += '.svg'
296
+ _, _ext = os.path.splitext(outname)
297
+
298
+ # Special handling for SVG: save with transparent background
299
+ # This allows figures to blend nicely into presentations/documents
300
+ if _ext.lower() == '.svg':
301
+ # Save current background colors so we can restore them
302
+ try:
303
+ _fig_fc = fig.get_facecolor()
304
+ except Exception:
305
+ _fig_fc = None
306
+ try:
307
+ _ax_fc = ax.get_facecolor()
308
+ except Exception:
309
+ _ax_fc = None
310
+
311
+ # Temporarily make backgrounds transparent
312
+ try:
313
+ if getattr(fig, 'patch', None) is not None:
314
+ fig.patch.set_alpha(0.0)
315
+ fig.patch.set_facecolor('none')
316
+ if getattr(ax, 'patch', None) is not None:
317
+ ax.patch.set_alpha(0.0)
318
+ ax.patch.set_facecolor('none')
319
+ except Exception:
320
+ pass
321
+
322
+ # Save with transparency
323
+ try:
324
+ fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
325
+ finally:
326
+ # Restore original backgrounds (for interactive display)
327
+ try:
328
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
329
+ fig.patch.set_alpha(1.0)
330
+ fig.patch.set_facecolor(_fig_fc)
331
+ except Exception:
332
+ pass
333
+ try:
334
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
335
+ ax.patch.set_alpha(1.0)
336
+ ax.patch.set_facecolor(_ax_fc)
337
+ except Exception:
338
+ pass
339
+ else:
340
+ # Other formats: simple save with high DPI
341
+ fig.savefig(outname, dpi=300)
342
+ print(f"CV plot saved to {outname}")
343
+
344
+ # Interactive menu
345
+ if args.interactive:
346
+ try:
347
+ _backend = plt.get_backend()
348
+ except Exception:
349
+ _backend = "unknown"
350
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
351
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
352
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
353
+ if _is_noninteractive:
354
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
355
+ print("Tips: unset MPLBACKEND or set a GUI backend")
356
+ print("Or run without --interactive and use --out to save the figure.")
357
+ else:
358
+ try:
359
+ plt.ion()
360
+ except Exception:
361
+ pass
362
+ plt.show(block=False)
363
+ try:
364
+ fig._bp_source_paths = [os.path.abspath(ec_file)]
365
+ except Exception:
366
+ pass
367
+ try:
368
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
369
+ except Exception as _ie:
370
+ print(f"Interactive menu failed: {_ie}")
371
+ plt.show()
372
+ else:
373
+ if not (args.savefig or args.out):
374
+ try:
375
+ _backend = plt.get_backend()
376
+ except Exception:
377
+ _backend = "unknown"
378
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
379
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
380
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
381
+ if not _is_noninteractive:
382
+ plt.show()
383
+ else:
384
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
385
+ return 0
386
+
387
+ except Exception as e:
388
+ print(f"CV plot failed: {e}")
389
+ return 1
390
+
391
+
392
+ def handle_gc_mode(args) -> int:
393
+ """Handle galvanostatic cycling (GC) plotting mode.
394
+
395
+ Galvanostatic cycling plots show voltage vs. capacity curves for each cycle.
396
+ This is the primary visualization for battery cycling data, showing charge/
397
+ discharge behavior, capacity fade, and voltage plateaus.
398
+
399
+ Features:
400
+ - Automatic cycle detection from file data or inferred from charge/discharge
401
+ - Each cycle plotted in unique color (charge and discharge together)
402
+ - Handles both specific capacity (.mpt with --mass, CSV) and raw capacity
403
+ - Supports discontinuous cycles (paused experiments)
404
+ - Interactive menu for customization
405
+
406
+ Data Flow:
407
+ 1. Validate input (single .mpt or .csv file)
408
+ 2. Read capacity, voltage, cycles, charge/discharge masks
409
+ 3. For .mpt: requires --mass parameter, calculates specific capacity
410
+ 4. For .csv: reads specific capacity directly from file
411
+ 5. Group data by cycle, split into charge/discharge segments
412
+ 6. Plot each cycle with unique color
413
+ 7. Launch interactive menu or save/show
414
+
415
+ Args:
416
+ args: Argument namespace containing:
417
+ - files: List with single file path (.mpt or .csv)
418
+ - mass: Active material mass in mg (required for .mpt files)
419
+ - interactive: bool, launch customization menu
420
+ - savefig/out: optional output filename
421
+ - raw: unused (legacy parameter)
422
+
423
+ Returns:
424
+ Exit code: 0 for success, 1 for error
425
+
426
+ File Format Requirements:
427
+ - .mpt: BioLogic native format, requires --mass parameter
428
+ - .csv: Neware or similar with capacity and cycle columns
429
+ """
430
+ # === INPUT VALIDATION ===
431
+ if len(args.files) != 1:
432
+ print("GC mode: provide exactly one file argument (.mpt or .csv).")
433
+ return 1
434
+
435
+ ec_file = args.files[0]
436
+ if not os.path.isfile(ec_file):
437
+ print(f"File not found: {ec_file}")
438
+ return 1
439
+
440
+ try:
441
+ # Branch by extension
442
+ if ec_file.lower().endswith('.mpt'):
443
+ mass_mg = getattr(args, 'mass', None)
444
+ if mass_mg is None:
445
+ print("GC mode (.mpt): --mass parameter is required (active material mass in milligrams).")
446
+ print("Example: batplot file.mpt --gc --mass 7.0")
447
+ return 1
448
+ specific_capacity, voltage, cycle_numbers, charge_mask, discharge_mask = read_mpt_file(ec_file, mode='gc', mass_mg=mass_mg)
449
+ x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
450
+ cap_x = specific_capacity
451
+ elif ec_file.lower().endswith('.csv'):
452
+ cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_ec_csv_file(ec_file, prefer_specific=True)
453
+ x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
454
+ else:
455
+ print("GC mode: file must be .mpt or .csv")
456
+ return 1
457
+
458
+ # Create the plot
459
+ fig, ax = plt.subplots(figsize=(10, 6))
460
+
461
+ # ====================================================================
462
+ # CYCLE PROCESSING: BUILD PER-CYCLE LINES FOR CHARGE AND DISCHARGE
463
+ # ====================================================================
464
+ # In GC mode, each cycle consists of two parts:
465
+ # 1. Charge segment: capacity increases, voltage increases
466
+ # 2. Discharge segment: capacity continues, voltage decreases
467
+ #
468
+ # We need to:
469
+ # - Group data points by cycle number
470
+ # - Separate charge and discharge segments within each cycle
471
+ # - Handle gaps in data (paused experiments)
472
+ # - Plot each cycle with a unique color
473
+ #
474
+ # Helper functions below handle the data segmentation.
475
+ # ====================================================================
476
+
477
+ def _contiguous_blocks(mask):
478
+ """
479
+ Find all contiguous blocks (runs) of True values in a boolean mask.
480
+
481
+ HOW IT WORKS:
482
+ ------------
483
+ Scans through indices where mask is True, looking for gaps.
484
+ Each continuous run becomes one block.
485
+
486
+ Example:
487
+ mask = [F, T, T, T, F, F, T, T, F]
488
+ indices = [1, 2, 3, 6, 7]
489
+ Blocks found: (1, 3) and (6, 7)
490
+
491
+ Returns:
492
+ List of (start_index, end_index) tuples for each contiguous block
493
+ """
494
+ inds = np.where(mask)[0] # Get all indices where mask is True
495
+ if inds.size == 0:
496
+ return []
497
+
498
+ blocks = []
499
+ start = inds[0] # Start of current block
500
+ prev = inds[0] # Previous index seen
501
+
502
+ # Scan through indices looking for gaps
503
+ for j in inds[1:]:
504
+ if j == prev + 1:
505
+ # Consecutive, continue current block
506
+ prev = j
507
+ else:
508
+ # Gap found, save current block and start new one
509
+ blocks.append((start, prev))
510
+ start = j
511
+ prev = j
512
+
513
+ # Don't forget the last block
514
+ blocks.append((start, prev))
515
+ return blocks
516
+
517
+ def _broken_arrays_from_indices(idx: np.ndarray, x: np.ndarray, y: np.ndarray):
518
+ """
519
+ Extract x and y data for given indices, handling gaps with NaN separators.
520
+
521
+ HOW IT WORKS:
522
+ ------------
523
+ If indices are not consecutive (e.g., [10, 11, 12, 50, 51, 52]),
524
+ we split into segments and insert NaN between them. This prevents
525
+ matplotlib from drawing lines across gaps.
526
+
527
+ Example:
528
+ idx = [10, 11, 12, 50, 51, 52]
529
+ x = [0, 1, 2, ..., 100]
530
+ y = [3.0, 3.1, 3.2, ..., 4.0]
531
+
532
+ Result:
533
+ x_b = [x[10], x[11], x[12], NaN, x[50], x[51], x[52]]
534
+ y_b = [y[10], y[11], y[12], NaN, y[50], y[51], y[52]]
535
+
536
+ This creates two separate line segments with no connecting line.
537
+
538
+ Args:
539
+ idx: Array of indices to extract
540
+ x: Full x data array
541
+ y: Full y data array
542
+
543
+ Returns:
544
+ Tuple of (x_broken, y_broken) arrays with NaN separators
545
+ """
546
+ if idx.size == 0:
547
+ return np.array([]), np.array([])
548
+
549
+ # Find continuous segments
550
+ parts_x = [] # Will store x segments
551
+ parts_y = [] # Will store y segments
552
+ start = 0 # Start of current segment
553
+
554
+ # Scan for gaps
555
+ for k in range(1, idx.size):
556
+ if idx[k] != idx[k-1] + 1: # Gap detected
557
+ # Save segment from start to k-1
558
+ parts_x.append(x[idx[start:k]])
559
+ parts_y.append(y[idx[start:k]])
560
+ start = k
561
+
562
+ # Save last segment
563
+ parts_x.append(x[idx[start:]])
564
+ parts_y.append(y[idx[start:]])
565
+
566
+ # Concatenate with NaN separators
567
+ X = []
568
+ Y = []
569
+ for i, (px, py) in enumerate(zip(parts_x, parts_y)):
570
+ if i > 0:
571
+ # Insert NaN between segments
572
+ X.append(np.array([np.nan]))
573
+ Y.append(np.array([np.nan]))
574
+ X.append(px)
575
+ Y.append(py)
576
+
577
+ return np.concatenate(X) if X else np.array([]), np.concatenate(Y) if Y else np.array([])
578
+
579
+ # ====================================================================
580
+ # CYCLE NUMBER PROCESSING
581
+ # ====================================================================
582
+ # Some files have explicit cycle numbers, others don't.
583
+ # We handle both cases:
584
+ #
585
+ # Case 1: File has cycle numbers
586
+ # - Normalize to start at 1 (same as CV mode)
587
+ # - Use cycle numbers directly
588
+ #
589
+ # Case 2: File has no cycle numbers (or only one cycle)
590
+ # - Infer cycles from charge/discharge segments
591
+ # - Each charge+discharge pair becomes one cycle
592
+ # ====================================================================
593
+
594
+ if cycle_numbers is not None:
595
+ # File has cycle numbers: normalize them to start at 1
596
+ cyc_int_raw = np.array(np.rint(cycle_numbers), dtype=int)
597
+ if cyc_int_raw.size:
598
+ min_c = int(np.min(cyc_int_raw))
599
+ else:
600
+ min_c = 1
601
+ shift = 1 - min_c if min_c <= 0 else 0
602
+ cyc_int = cyc_int_raw + shift
603
+ cycles_present = sorted(int(c) for c in np.unique(cyc_int))
604
+ else:
605
+ # No cycle numbers in file
606
+ cycles_present = [1]
607
+
608
+ # ====================================================================
609
+ # DETERMINE IF WE NEED TO INFER CYCLES
610
+ # ====================================================================
611
+ # If file has only 1 cycle (or none), we infer cycles from charge/discharge
612
+ # segments. This handles files where cycle numbers weren't recorded.
613
+ #
614
+ # Inference method:
615
+ # - Find all contiguous charge blocks
616
+ # - Find all contiguous discharge blocks
617
+ # - Pair them sequentially: block 0+1 = Cycle 1, block 2+3 = Cycle 2, etc.
618
+ # ====================================================================
619
+ inferred = len(cycles_present) <= 1
620
+ if inferred:
621
+ # Infer cycles from charge/discharge segments
622
+ ch_blocks = _contiguous_blocks(charge_mask) # All charge segments
623
+ dch_blocks = _contiguous_blocks(discharge_mask) # All discharge segments
624
+
625
+ # Number of cycles = max of charge or discharge segments
626
+ # (Some experiments might start with charge, others with discharge)
627
+ cycles_present = list(range(1, max(len(ch_blocks), len(dch_blocks)) + 1)) if (ch_blocks or dch_blocks) else [1]
628
+
629
+ base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
630
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
631
+
632
+ cycle_lines = {}
633
+
634
+ if not inferred and cycle_numbers is not None:
635
+ for cyc in cycles_present:
636
+ mask_c = (cyc_int == cyc) & charge_mask
637
+ idx = np.where(mask_c)[0]
638
+ if idx.size >= 2:
639
+ x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
640
+ # Swap x and y if --ro flag is set
641
+ if getattr(args, 'ro', False):
642
+ ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
643
+ linewidth=2.0, label=str(cyc), alpha=0.8)
644
+ else:
645
+ ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
646
+ linewidth=2.0, label=str(cyc), alpha=0.8)
647
+ else:
648
+ ln_c = None
649
+ mask_d = (cyc_int == cyc) & discharge_mask
650
+ idxd = np.where(mask_d)[0]
651
+ if idxd.size >= 2:
652
+ xd_b, yd_b = _broken_arrays_from_indices(idxd, cap_x, voltage)
653
+ lbl = '_nolegend_' if ln_c is not None else str(cyc)
654
+ # Swap x and y if --ro flag is set
655
+ if getattr(args, 'ro', False):
656
+ ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
657
+ linewidth=2.0, label=lbl, alpha=0.8)
658
+ else:
659
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
660
+ linewidth=2.0, label=lbl, alpha=0.8)
661
+ else:
662
+ ln_d = None
663
+ cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
664
+ else:
665
+ ch_blocks = _contiguous_blocks(charge_mask)
666
+ dch_blocks = _contiguous_blocks(discharge_mask)
667
+ N = max(len(ch_blocks), len(dch_blocks))
668
+ for i in range(N):
669
+ cyc = i + 1
670
+ ln_c = None
671
+ if i < len(ch_blocks):
672
+ a, b = ch_blocks[i]
673
+ idx = np.arange(a, b + 1)
674
+ x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
675
+ # Swap x and y if --ro flag is set
676
+ if getattr(args, 'ro', False):
677
+ ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
678
+ linewidth=2.0, label=str(cyc), alpha=0.8)
679
+ else:
680
+ ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
681
+ linewidth=2.0, label=str(cyc), alpha=0.8)
682
+ ln_d = None
683
+ if i < len(dch_blocks):
684
+ a, b = dch_blocks[i]
685
+ idx = np.arange(a, b + 1)
686
+ xd_b, yd_b = _broken_arrays_from_indices(idx, cap_x, voltage)
687
+ lbl = '_nolegend_' if ln_c is not None else str(cyc)
688
+ # Swap x and y if --ro flag is set
689
+ if getattr(args, 'ro', False):
690
+ ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
691
+ linewidth=2.0, label=lbl, alpha=0.8)
692
+ else:
693
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
694
+ linewidth=2.0, label=lbl, alpha=0.8)
695
+ cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
696
+
697
+ # Swap x and y if --ro flag is set
698
+ if getattr(args, 'ro', False):
699
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
700
+ ax.set_ylabel(x_label_gc, labelpad=8.0)
701
+ else:
702
+ ax.set_xlabel(x_label_gc, labelpad=8.0)
703
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
704
+ legend = ax.legend(title='Cycle')
705
+ legend.get_title().set_fontsize('medium')
706
+ fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
707
+
708
+ # Save if requested
709
+ outname = args.savefig or args.out
710
+ if outname:
711
+ if not os.path.splitext(outname)[1]:
712
+ outname += '.svg'
713
+ _, _ext = os.path.splitext(outname)
714
+ if _ext.lower() == '.svg':
715
+ try:
716
+ _fig_fc = fig.get_facecolor()
717
+ except Exception:
718
+ _fig_fc = None
719
+ try:
720
+ _ax_fc = ax.get_facecolor()
721
+ except Exception:
722
+ _ax_fc = None
723
+ try:
724
+ if getattr(fig, 'patch', None) is not None:
725
+ fig.patch.set_alpha(0.0)
726
+ fig.patch.set_facecolor('none')
727
+ if getattr(ax, 'patch', None) is not None:
728
+ ax.patch.set_alpha(0.0)
729
+ ax.patch.set_facecolor('none')
730
+ except Exception:
731
+ pass
732
+ try:
733
+ fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
734
+ finally:
735
+ try:
736
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
737
+ fig.patch.set_alpha(1.0)
738
+ fig.patch.set_facecolor(_fig_fc)
739
+ except Exception:
740
+ pass
741
+ try:
742
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
743
+ ax.patch.set_alpha(1.0)
744
+ ax.patch.set_facecolor(_ax_fc)
745
+ except Exception:
746
+ pass
747
+ else:
748
+ fig.savefig(outname, dpi=300)
749
+ print(f"GC plot saved to {outname} ({x_label_gc})")
750
+
751
+ # Show plot / interactive menu
752
+ if args.interactive:
753
+ try:
754
+ _backend = plt.get_backend()
755
+ except Exception:
756
+ _backend = "unknown"
757
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
758
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
759
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
760
+ if _is_noninteractive:
761
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
762
+ print("Tips: unset MPLBACKEND or set a GUI backend")
763
+ print("Or run without --interactive and use --out to save the figure.")
764
+ else:
765
+ try:
766
+ plt.ion()
767
+ except Exception:
768
+ pass
769
+ plt.show(block=False)
770
+ try:
771
+ fig._bp_source_paths = [os.path.abspath(ec_file)]
772
+ except Exception:
773
+ pass
774
+ try:
775
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
776
+ except Exception as _ie:
777
+ print(f"Interactive menu failed: {_ie}")
778
+ plt.show()
779
+ else:
780
+ if not (args.savefig or args.out):
781
+ try:
782
+ _backend = plt.get_backend()
783
+ except Exception:
784
+ _backend = "unknown"
785
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
786
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
787
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
788
+ if not _is_noninteractive:
789
+ plt.show()
790
+ else:
791
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
792
+ return 0
793
+
794
+ except Exception as _e:
795
+ print(f"GC plot failed: {_e}")
796
+ return 1
797
+
798
+
799
+ __all__ = ['handle_cv_mode', 'handle_gc_mode']