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.
- batplot/__init__.py +1 -1
- batplot/args.py +2 -0
- batplot/batplot.py +44 -4
- batplot/cpc_interactive.py +10 -0
- batplot/interactive.py +10 -0
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +17 -0
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
- batplot_backup_20251221_101150/__init__.py +5 -0
- batplot_backup_20251221_101150/args.py +625 -0
- batplot_backup_20251221_101150/batch.py +1176 -0
- batplot_backup_20251221_101150/batplot.py +3589 -0
- batplot_backup_20251221_101150/cif.py +823 -0
- batplot_backup_20251221_101150/cli.py +149 -0
- batplot_backup_20251221_101150/color_utils.py +547 -0
- batplot_backup_20251221_101150/config.py +198 -0
- batplot_backup_20251221_101150/converters.py +204 -0
- batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
- batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
- batplot_backup_20251221_101150/interactive.py +3894 -0
- batplot_backup_20251221_101150/manual.py +323 -0
- batplot_backup_20251221_101150/modes.py +799 -0
- batplot_backup_20251221_101150/operando.py +603 -0
- batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
- batplot_backup_20251221_101150/plotting.py +228 -0
- batplot_backup_20251221_101150/readers.py +2607 -0
- batplot_backup_20251221_101150/session.py +2951 -0
- batplot_backup_20251221_101150/style.py +1441 -0
- batplot_backup_20251221_101150/ui.py +790 -0
- batplot_backup_20251221_101150/utils.py +1046 -0
- batplot_backup_20251221_101150/version_check.py +253 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
- {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']
|