batplot 1.8.1__py3-none-any.whl → 1.8.3__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/batch.py +23 -0
- batplot/batplot.py +101 -12
- batplot/cpc_interactive.py +25 -3
- batplot/electrochem_interactive.py +20 -4
- batplot/interactive.py +19 -15
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +218 -0
- batplot/style.py +21 -2
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
- batplot-1.8.3.dist-info/RECORD +75 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.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/RECORD +0 -52
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
"""Batch processing for exporting plots to SVG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
|
|
10
|
+
from .readers import (
|
|
11
|
+
read_gr_file,
|
|
12
|
+
robust_loadtxt_skipheader,
|
|
13
|
+
read_mpt_file,
|
|
14
|
+
read_ec_csv_file,
|
|
15
|
+
read_ec_csv_dqdv_file,
|
|
16
|
+
)
|
|
17
|
+
from .utils import _confirm_overwrite
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_style_file(style_path: str) -> dict | None:
|
|
21
|
+
"""Load a .bps, .bpsg, or .bpcfg style file.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
style_path: Path to style configuration file
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Style configuration dict or None if loading fails
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
with open(style_path, 'r', encoding='utf-8') as f:
|
|
31
|
+
cfg = json.load(f)
|
|
32
|
+
return cfg
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Warning: Could not load style file {style_path}: {e}")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _apply_xy_style(fig, ax, cfg: dict):
|
|
39
|
+
"""Apply style configuration to an XY batch plot.
|
|
40
|
+
|
|
41
|
+
Applies formatting from .bps/.bpsg files including fonts, colors,
|
|
42
|
+
tick parameters, and geometry (if present in .bpsg files).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
fig: Matplotlib figure object
|
|
46
|
+
ax: Matplotlib axes object
|
|
47
|
+
cfg: Style configuration dictionary
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
# Apply fonts
|
|
51
|
+
font_cfg = cfg.get('font', {})
|
|
52
|
+
if font_cfg:
|
|
53
|
+
family = font_cfg.get('family')
|
|
54
|
+
size = font_cfg.get('size')
|
|
55
|
+
if family:
|
|
56
|
+
plt.rcParams['font.sans-serif'] = [family] if isinstance(family, str) else family
|
|
57
|
+
if size is not None:
|
|
58
|
+
plt.rcParams['font.size'] = size
|
|
59
|
+
|
|
60
|
+
# Apply figure size if present
|
|
61
|
+
fig_cfg = cfg.get('figure', {})
|
|
62
|
+
if fig_cfg:
|
|
63
|
+
canvas_size = fig_cfg.get('canvas_size')
|
|
64
|
+
if canvas_size and isinstance(canvas_size, (list, tuple)) and len(canvas_size) == 2:
|
|
65
|
+
try:
|
|
66
|
+
fig.set_size_inches(canvas_size[0], canvas_size[1])
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# Apply tick parameters
|
|
71
|
+
ticks_cfg = cfg.get('ticks', {})
|
|
72
|
+
if ticks_cfg:
|
|
73
|
+
# Tick widths
|
|
74
|
+
widths = ticks_cfg.get('widths', {})
|
|
75
|
+
if widths.get('x_major') is not None:
|
|
76
|
+
ax.tick_params(axis='x', which='major', width=widths['x_major'])
|
|
77
|
+
if widths.get('x_minor') is not None:
|
|
78
|
+
ax.tick_params(axis='x', which='minor', width=widths['x_minor'])
|
|
79
|
+
if widths.get('y_major') is not None:
|
|
80
|
+
ax.tick_params(axis='y', which='major', width=widths['y_major'])
|
|
81
|
+
if widths.get('y_minor') is not None:
|
|
82
|
+
ax.tick_params(axis='y', which='minor', width=widths['y_minor'])
|
|
83
|
+
|
|
84
|
+
# Tick lengths
|
|
85
|
+
lengths = ticks_cfg.get('lengths', {})
|
|
86
|
+
if lengths.get('major') is not None:
|
|
87
|
+
ax.tick_params(axis='both', which='major', length=lengths['major'])
|
|
88
|
+
if lengths.get('minor') is not None:
|
|
89
|
+
ax.tick_params(axis='both', which='minor', length=lengths['minor'])
|
|
90
|
+
|
|
91
|
+
# Tick direction
|
|
92
|
+
direction = ticks_cfg.get('direction')
|
|
93
|
+
if direction:
|
|
94
|
+
ax.tick_params(axis='both', which='both', direction=direction)
|
|
95
|
+
|
|
96
|
+
# Apply geometry if present (for .bpsg files)
|
|
97
|
+
kind = cfg.get('kind', '')
|
|
98
|
+
if 'geom' in kind.lower() and 'geometry' in cfg:
|
|
99
|
+
geom = cfg.get('geometry', {})
|
|
100
|
+
if geom.get('xlabel'):
|
|
101
|
+
ax.set_xlabel(geom['xlabel'])
|
|
102
|
+
if geom.get('ylabel'):
|
|
103
|
+
ax.set_ylabel(geom['ylabel'])
|
|
104
|
+
if 'xlim' in geom and isinstance(geom['xlim'], (list, tuple)) and len(geom['xlim']) == 2:
|
|
105
|
+
try:
|
|
106
|
+
ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if 'ylim' in geom and isinstance(geom['ylim'], (list, tuple)) and len(geom['ylim']) == 2:
|
|
110
|
+
try:
|
|
111
|
+
ax.set_ylim(geom['ylim'][0], geom['ylim'][1])
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# Apply line colors if available
|
|
116
|
+
lines_cfg = cfg.get('lines', [])
|
|
117
|
+
if lines_cfg and len(ax.lines) > 0:
|
|
118
|
+
for entry in lines_cfg:
|
|
119
|
+
idx = entry.get('index')
|
|
120
|
+
if idx is not None and 0 <= idx < len(ax.lines):
|
|
121
|
+
ln = ax.lines[idx]
|
|
122
|
+
if 'color' in entry:
|
|
123
|
+
try:
|
|
124
|
+
ln.set_color(entry['color'])
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
if 'linewidth' in entry:
|
|
128
|
+
try:
|
|
129
|
+
ln.set_linewidth(entry['linewidth'])
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
if 'linestyle' in entry:
|
|
133
|
+
try:
|
|
134
|
+
ln.set_linestyle(entry['linestyle'])
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Apply spine configuration
|
|
139
|
+
spines_cfg = cfg.get('spines', {})
|
|
140
|
+
for spine_name, spine_props in spines_cfg.items():
|
|
141
|
+
if spine_name in ax.spines:
|
|
142
|
+
sp = ax.spines[spine_name]
|
|
143
|
+
if 'lw' in spine_props or 'linewidth' in spine_props:
|
|
144
|
+
try:
|
|
145
|
+
lw = spine_props.get('lw') or spine_props.get('linewidth')
|
|
146
|
+
sp.set_linewidth(lw)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
if 'color' in spine_props:
|
|
150
|
+
try:
|
|
151
|
+
sp.set_edgecolor(spine_props['color'])
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
if 'visible' in spine_props:
|
|
155
|
+
try:
|
|
156
|
+
sp.set_visible(spine_props['visible'])
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Apply WASD state (tick visibility)
|
|
161
|
+
wasd_cfg = cfg.get('wasd_state', {})
|
|
162
|
+
if wasd_cfg:
|
|
163
|
+
# Top ticks (W)
|
|
164
|
+
if 'top' in wasd_cfg:
|
|
165
|
+
top_cfg = wasd_cfg['top']
|
|
166
|
+
if isinstance(top_cfg, dict):
|
|
167
|
+
ticks_on = top_cfg.get('ticks', False)
|
|
168
|
+
labels_on = top_cfg.get('labels', False)
|
|
169
|
+
ax.tick_params(axis='x', top=ticks_on, labeltop=labels_on)
|
|
170
|
+
# Left ticks (A)
|
|
171
|
+
if 'left' in wasd_cfg:
|
|
172
|
+
left_cfg = wasd_cfg['left']
|
|
173
|
+
if isinstance(left_cfg, dict):
|
|
174
|
+
ticks_on = left_cfg.get('ticks', False)
|
|
175
|
+
labels_on = left_cfg.get('labels', False)
|
|
176
|
+
ax.tick_params(axis='y', left=ticks_on, labelleft=labels_on)
|
|
177
|
+
# Bottom ticks (S)
|
|
178
|
+
if 'bottom' in wasd_cfg:
|
|
179
|
+
bottom_cfg = wasd_cfg['bottom']
|
|
180
|
+
if isinstance(bottom_cfg, dict):
|
|
181
|
+
ticks_on = bottom_cfg.get('ticks', True)
|
|
182
|
+
labels_on = bottom_cfg.get('labels', True)
|
|
183
|
+
ax.tick_params(axis='x', bottom=ticks_on, labelbottom=labels_on)
|
|
184
|
+
# Right ticks (D)
|
|
185
|
+
if 'right' in wasd_cfg:
|
|
186
|
+
right_cfg = wasd_cfg['right']
|
|
187
|
+
if isinstance(right_cfg, dict):
|
|
188
|
+
ticks_on = right_cfg.get('ticks', False)
|
|
189
|
+
labels_on = right_cfg.get('labels', False)
|
|
190
|
+
ax.tick_params(axis='y', right=ticks_on, labelright=labels_on)
|
|
191
|
+
|
|
192
|
+
# Apply rotation
|
|
193
|
+
rotation_cfg = cfg.get('rotation', {})
|
|
194
|
+
if rotation_cfg:
|
|
195
|
+
x_rotation = rotation_cfg.get('x')
|
|
196
|
+
y_rotation = rotation_cfg.get('y')
|
|
197
|
+
if x_rotation is not None:
|
|
198
|
+
try:
|
|
199
|
+
for label in ax.get_xticklabels():
|
|
200
|
+
label.set_rotation(x_rotation)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
if y_rotation is not None:
|
|
204
|
+
try:
|
|
205
|
+
for label in ax.get_yticklabels():
|
|
206
|
+
label.set_rotation(y_rotation)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"Warning: Error applying style: {e}")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _apply_ec_style(fig, ax, cfg: dict):
|
|
215
|
+
"""Apply style configuration to an EC batch plot.
|
|
216
|
+
|
|
217
|
+
Applies formatting from .bps/.bpsg files including fonts, colors,
|
|
218
|
+
tick parameters, and geometry (if present in .bpsg files).
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
fig: Matplotlib figure object
|
|
222
|
+
ax: Matplotlib axes object
|
|
223
|
+
cfg: Style configuration dictionary
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
# Apply fonts
|
|
227
|
+
font_cfg = cfg.get('font', {})
|
|
228
|
+
if font_cfg:
|
|
229
|
+
family = font_cfg.get('family')
|
|
230
|
+
size = font_cfg.get('size')
|
|
231
|
+
if family:
|
|
232
|
+
plt.rcParams['font.sans-serif'] = [family] if isinstance(family, str) else family
|
|
233
|
+
if size is not None:
|
|
234
|
+
plt.rcParams['font.size'] = size
|
|
235
|
+
|
|
236
|
+
# Apply figure size if present
|
|
237
|
+
fig_cfg = cfg.get('figure', {})
|
|
238
|
+
if fig_cfg:
|
|
239
|
+
canvas_size = fig_cfg.get('canvas_size')
|
|
240
|
+
if canvas_size and isinstance(canvas_size, (list, tuple)) and len(canvas_size) == 2:
|
|
241
|
+
try:
|
|
242
|
+
fig.set_size_inches(canvas_size[0], canvas_size[1])
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Apply tick parameters
|
|
247
|
+
ticks_cfg = cfg.get('ticks', {})
|
|
248
|
+
if ticks_cfg:
|
|
249
|
+
# Tick widths
|
|
250
|
+
widths = ticks_cfg.get('widths', {})
|
|
251
|
+
if widths.get('x_major') is not None:
|
|
252
|
+
ax.tick_params(axis='x', which='major', width=widths['x_major'])
|
|
253
|
+
if widths.get('x_minor') is not None:
|
|
254
|
+
ax.tick_params(axis='x', which='minor', width=widths['x_minor'])
|
|
255
|
+
if widths.get('y_major') is not None or widths.get('ly_major') is not None:
|
|
256
|
+
w = widths.get('y_major') or widths.get('ly_major')
|
|
257
|
+
ax.tick_params(axis='y', which='major', width=w)
|
|
258
|
+
if widths.get('y_minor') is not None or widths.get('ly_minor') is not None:
|
|
259
|
+
w = widths.get('y_minor') or widths.get('ly_minor')
|
|
260
|
+
ax.tick_params(axis='y', which='minor', width=w)
|
|
261
|
+
|
|
262
|
+
# Tick lengths
|
|
263
|
+
lengths = ticks_cfg.get('lengths', {})
|
|
264
|
+
if lengths.get('major') is not None:
|
|
265
|
+
ax.tick_params(axis='both', which='major', length=lengths['major'])
|
|
266
|
+
if lengths.get('minor') is not None:
|
|
267
|
+
ax.tick_params(axis='both', which='minor', length=lengths['minor'])
|
|
268
|
+
|
|
269
|
+
# Tick direction
|
|
270
|
+
direction = ticks_cfg.get('direction')
|
|
271
|
+
if direction:
|
|
272
|
+
ax.tick_params(axis='both', which='both', direction=direction)
|
|
273
|
+
|
|
274
|
+
# Apply geometry if present (for .bpsg files)
|
|
275
|
+
kind = cfg.get('kind', '')
|
|
276
|
+
if 'geom' in kind.lower() and 'geometry' in cfg:
|
|
277
|
+
geom = cfg.get('geometry', {})
|
|
278
|
+
if geom.get('xlabel'):
|
|
279
|
+
ax.set_xlabel(geom['xlabel'])
|
|
280
|
+
if geom.get('ylabel'):
|
|
281
|
+
ax.set_ylabel(geom['ylabel'])
|
|
282
|
+
if 'xlim' in geom and isinstance(geom['xlim'], (list, tuple)) and len(geom['xlim']) == 2:
|
|
283
|
+
try:
|
|
284
|
+
ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
if 'ylim' in geom and isinstance(geom['ylim'], (list, tuple)) and len(geom['ylim']) == 2:
|
|
288
|
+
try:
|
|
289
|
+
ax.set_ylim(geom['ylim'][0], geom['ylim'][1])
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
# Apply line colors if available (for GC/CV/dQdV modes)
|
|
294
|
+
lines_cfg = cfg.get('lines', [])
|
|
295
|
+
if lines_cfg and len(ax.lines) > 0:
|
|
296
|
+
for entry in lines_cfg:
|
|
297
|
+
idx = entry.get('index')
|
|
298
|
+
if idx is not None and 0 <= idx < len(ax.lines):
|
|
299
|
+
ln = ax.lines[idx]
|
|
300
|
+
if 'color' in entry:
|
|
301
|
+
try:
|
|
302
|
+
ln.set_color(entry['color'])
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
305
|
+
if 'linewidth' in entry:
|
|
306
|
+
try:
|
|
307
|
+
ln.set_linewidth(entry['linewidth'])
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
if 'linestyle' in entry:
|
|
311
|
+
try:
|
|
312
|
+
ln.set_linestyle(entry['linestyle'])
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
# Apply spine configuration
|
|
317
|
+
spines_cfg = cfg.get('spines', {})
|
|
318
|
+
for spine_name, spine_props in spines_cfg.items():
|
|
319
|
+
if spine_name in ax.spines:
|
|
320
|
+
sp = ax.spines[spine_name]
|
|
321
|
+
if 'lw' in spine_props or 'linewidth' in spine_props:
|
|
322
|
+
try:
|
|
323
|
+
lw = spine_props.get('lw') or spine_props.get('linewidth')
|
|
324
|
+
sp.set_linewidth(lw)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
if 'color' in spine_props:
|
|
328
|
+
try:
|
|
329
|
+
sp.set_edgecolor(spine_props['color'])
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
if 'visible' in spine_props:
|
|
333
|
+
try:
|
|
334
|
+
sp.set_visible(spine_props['visible'])
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
|
|
338
|
+
# Apply WASD state (tick visibility)
|
|
339
|
+
wasd_cfg = cfg.get('wasd_state', {})
|
|
340
|
+
if wasd_cfg:
|
|
341
|
+
# Top ticks (W)
|
|
342
|
+
if 'top' in wasd_cfg:
|
|
343
|
+
ax.tick_params(top=wasd_cfg['top'], labeltop=wasd_cfg['top'])
|
|
344
|
+
# Left ticks (A)
|
|
345
|
+
if 'left' in wasd_cfg:
|
|
346
|
+
ax.tick_params(left=wasd_cfg['left'], labelleft=wasd_cfg['left'])
|
|
347
|
+
# Bottom ticks (S)
|
|
348
|
+
if 'bottom' in wasd_cfg:
|
|
349
|
+
ax.tick_params(bottom=wasd_cfg['bottom'], labelbottom=wasd_cfg['bottom'])
|
|
350
|
+
# Right ticks (D)
|
|
351
|
+
if 'right' in wasd_cfg:
|
|
352
|
+
ax.tick_params(right=wasd_cfg['right'], labelright=wasd_cfg['right'])
|
|
353
|
+
|
|
354
|
+
# Apply rotation
|
|
355
|
+
rotation_cfg = cfg.get('rotation', {})
|
|
356
|
+
if rotation_cfg:
|
|
357
|
+
x_rotation = rotation_cfg.get('x')
|
|
358
|
+
y_rotation = rotation_cfg.get('y')
|
|
359
|
+
if x_rotation is not None:
|
|
360
|
+
try:
|
|
361
|
+
for label in ax.get_xticklabels():
|
|
362
|
+
label.set_rotation(x_rotation)
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
if y_rotation is not None:
|
|
366
|
+
try:
|
|
367
|
+
for label in ax.get_yticklabels():
|
|
368
|
+
label.set_rotation(y_rotation)
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
print(f"Warning: Error applying style: {e}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def batch_process(directory: str, args):
|
|
377
|
+
"""
|
|
378
|
+
Batch process all data files in a directory, creating individual plots for each file.
|
|
379
|
+
|
|
380
|
+
HOW BATCH MODE WORKS:
|
|
381
|
+
--------------------
|
|
382
|
+
This function automates the process of creating plots for many files at once.
|
|
383
|
+
Instead of plotting files one by one, you can process an entire directory.
|
|
384
|
+
|
|
385
|
+
Workflow:
|
|
386
|
+
1. Scan directory for data files (XY format: 2-column x,y data)
|
|
387
|
+
2. For each file:
|
|
388
|
+
a. Read the data (x, y, optional error bars)
|
|
389
|
+
b. Determine axis type (Q, 2theta, r, energy, k, rft) from file extension or --xaxis flag
|
|
390
|
+
c. Create a matplotlib figure with the data
|
|
391
|
+
d. Apply style file if available (from --all flag or per-file style)
|
|
392
|
+
e. Save as SVG (or PNG if --format png)
|
|
393
|
+
3. All plots saved to Figures/ subdirectory
|
|
394
|
+
|
|
395
|
+
FILE TYPE DETECTION:
|
|
396
|
+
-------------------
|
|
397
|
+
The function automatically detects file types from extensions:
|
|
398
|
+
- .qye → Q-space (momentum transfer, Å⁻¹)
|
|
399
|
+
- .gr → r-space (PDF, Pair Distribution Function, Å)
|
|
400
|
+
- .nor → Energy space (XAS, eV)
|
|
401
|
+
- .chik → k-space (EXAFS, Å⁻¹)
|
|
402
|
+
- .chir → r-space (Fourier transform of EXAFS, Å)
|
|
403
|
+
- .xy, .xye, .dat, .csv, .txt → Generic 2-column data (requires --xaxis flag)
|
|
404
|
+
|
|
405
|
+
STYLE FILE SUPPORT:
|
|
406
|
+
------------------
|
|
407
|
+
You can apply styles in two ways:
|
|
408
|
+
1. Global style: batplot --all style.bps (applies same style to all files)
|
|
409
|
+
2. Per-file style: If style file has same name as data file (e.g., data.xy + data.bps)
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
directory: Path to directory containing data files
|
|
413
|
+
args: Argument namespace with batch processing options:
|
|
414
|
+
- all: Style file path (if provided) or 'all' string
|
|
415
|
+
- xaxis: X-axis type for unknown extensions (Q, 2theta, r, energy, k, rft)
|
|
416
|
+
- xrange: Optional X-axis range (min, max)
|
|
417
|
+
- yrange: Optional Y-axis range (min, max)
|
|
418
|
+
- format: Output format ('svg' or 'png', default 'svg')
|
|
419
|
+
- wl: Wavelength for 2theta→Q conversion (if needed)
|
|
420
|
+
- norm: Normalize Y data to 0-1 range
|
|
421
|
+
"""
|
|
422
|
+
print(f"Batch mode: scanning {directory}")
|
|
423
|
+
|
|
424
|
+
# ====================================================================
|
|
425
|
+
# FILE EXTENSION CLASSIFICATION
|
|
426
|
+
# ====================================================================
|
|
427
|
+
# We classify file extensions into three categories:
|
|
428
|
+
# 1. Known extensions with automatic axis detection (don't need --xaxis)
|
|
429
|
+
# 2. Known generic extensions (need --xaxis if axis type unclear)
|
|
430
|
+
# 3. Excluded extensions (not data files, skip them)
|
|
431
|
+
# ====================================================================
|
|
432
|
+
|
|
433
|
+
# Extensions that automatically determine axis type (no --xaxis needed)
|
|
434
|
+
known_axis_ext = {'.qye', '.gr', '.nor', '.chik', '.chir'}
|
|
435
|
+
|
|
436
|
+
# All acceptable data file extensions (includes both auto-detect and generic)
|
|
437
|
+
known_ext = {'.xye', '.xy', '.qye', '.dat', '.csv', '.gr', '.nor', '.chik', '.chir', '.txt'}
|
|
438
|
+
|
|
439
|
+
# Extensions to exclude (not data files, or require special handling)
|
|
440
|
+
excluded_ext = {'.cif', '.pkl', '.py', '.md', '.json', '.yml', '.yaml', '.sh', '.bat', '.mpt'}
|
|
441
|
+
|
|
442
|
+
# Create output directory for saved plots
|
|
443
|
+
from .utils import ensure_subdirectory
|
|
444
|
+
out_dir = ensure_subdirectory('Figures', directory)
|
|
445
|
+
|
|
446
|
+
# Check if --all flag was used with a style file
|
|
447
|
+
style_cfg = None
|
|
448
|
+
style_file_arg = getattr(args, 'all', None)
|
|
449
|
+
if style_file_arg and style_file_arg != 'all':
|
|
450
|
+
# User provided a style file path - resolve it properly
|
|
451
|
+
# Handle absolute paths, relative paths, and paths with/without extensions
|
|
452
|
+
style_path = None
|
|
453
|
+
if os.path.isabs(style_file_arg):
|
|
454
|
+
# Absolute path provided
|
|
455
|
+
style_path = os.path.normpath(style_file_arg)
|
|
456
|
+
else:
|
|
457
|
+
# Relative path - try multiple locations
|
|
458
|
+
# 1. Relative to current working directory
|
|
459
|
+
cwd_path = os.path.normpath(os.path.join(os.getcwd(), style_file_arg))
|
|
460
|
+
# 2. Relative to batch directory
|
|
461
|
+
dir_path = os.path.normpath(os.path.join(directory, style_file_arg))
|
|
462
|
+
|
|
463
|
+
# Try current working directory first (for paths like ./Style/style.bps)
|
|
464
|
+
if os.path.exists(cwd_path) and cwd_path.lower().endswith(('.bps', '.bpsg', '.bpcfg')):
|
|
465
|
+
style_path = cwd_path
|
|
466
|
+
elif os.path.exists(dir_path) and dir_path.lower().endswith(('.bps', '.bpsg', '.bpcfg')):
|
|
467
|
+
style_path = dir_path
|
|
468
|
+
else:
|
|
469
|
+
# Try adding extensions if not present
|
|
470
|
+
for test_path in [cwd_path, dir_path]:
|
|
471
|
+
for ext in ['.bps', '.bpsg', '.bpcfg']:
|
|
472
|
+
if test_path.lower().endswith(ext):
|
|
473
|
+
continue
|
|
474
|
+
test_full = test_path + ext
|
|
475
|
+
if os.path.exists(test_full):
|
|
476
|
+
style_path = test_full
|
|
477
|
+
break
|
|
478
|
+
if style_path:
|
|
479
|
+
break
|
|
480
|
+
|
|
481
|
+
if style_path and os.path.exists(style_path):
|
|
482
|
+
style_cfg = _load_style_file(style_path)
|
|
483
|
+
if style_cfg:
|
|
484
|
+
print(f"Using style file: {os.path.basename(style_path)}")
|
|
485
|
+
else:
|
|
486
|
+
print(f"Warning: Could not find style file '{style_file_arg}'")
|
|
487
|
+
|
|
488
|
+
# Collect all files, including those with unknown extensions
|
|
489
|
+
files = []
|
|
490
|
+
unknown_ext_files = []
|
|
491
|
+
for f in sorted(os.listdir(directory)):
|
|
492
|
+
if not os.path.isfile(os.path.join(directory, f)):
|
|
493
|
+
continue
|
|
494
|
+
ext = os.path.splitext(f)[1].lower()
|
|
495
|
+
# Skip excluded extensions, style files, and files without extensions
|
|
496
|
+
if ext in excluded_ext or ext in ('.bps', '.bpsg', '.bpcfg') or not ext:
|
|
497
|
+
continue
|
|
498
|
+
# Include known extensions
|
|
499
|
+
if ext in known_ext:
|
|
500
|
+
files.append(f)
|
|
501
|
+
else:
|
|
502
|
+
# Include unknown extensions (require --xaxis)
|
|
503
|
+
files.append(f)
|
|
504
|
+
unknown_ext_files.append(f)
|
|
505
|
+
|
|
506
|
+
if not files:
|
|
507
|
+
print("No data files found.")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
# Check if --xaxis is required for unknown extensions
|
|
511
|
+
if unknown_ext_files and not args.xaxis:
|
|
512
|
+
print(f"Error: Found {len(unknown_ext_files)} file(s) with unknown extension(s) that require --xaxis:")
|
|
513
|
+
for uf in unknown_ext_files[:5]: # Show first 5
|
|
514
|
+
print(f" - {uf}")
|
|
515
|
+
if len(unknown_ext_files) > 5:
|
|
516
|
+
print(f" ... and {len(unknown_ext_files) - 5} more")
|
|
517
|
+
print("\nKnown extensions that don't require --xaxis: .qye, .gr, .nor, .chik, .chir")
|
|
518
|
+
print("Please specify x-axis type with --xaxis (options: 2theta, Q, r, energy, k, rft)")
|
|
519
|
+
print("Example: batplot --all --xaxis 2theta")
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
if unknown_ext_files:
|
|
523
|
+
print(f"Note: Processing {len(unknown_ext_files)} file(s) with unknown extension(s) using --xaxis {args.xaxis}")
|
|
524
|
+
|
|
525
|
+
print(f"Found {len(files)} files. Exporting SVG plots to Figures/")
|
|
526
|
+
|
|
527
|
+
# ====================================================================
|
|
528
|
+
# PROCESS EACH FILE
|
|
529
|
+
# ====================================================================
|
|
530
|
+
# Loop through each data file and create a plot for it.
|
|
531
|
+
# Each file becomes a separate figure saved to Figures/ directory.
|
|
532
|
+
# ====================================================================
|
|
533
|
+
for fname in files:
|
|
534
|
+
fpath = os.path.join(directory, fname)
|
|
535
|
+
ext = os.path.splitext(fname)[1].lower()
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
# ============================================================
|
|
539
|
+
# STEP 1: READ DATA FROM FILE
|
|
540
|
+
# ============================================================
|
|
541
|
+
# Different file formats require different reading methods.
|
|
542
|
+
# We detect the format from the file extension and use the
|
|
543
|
+
# appropriate reader function.
|
|
544
|
+
# ============================================================
|
|
545
|
+
|
|
546
|
+
if ext == '.gr':
|
|
547
|
+
x, y = read_gr_file(fpath); e = None
|
|
548
|
+
axis_mode = 'r'
|
|
549
|
+
elif ext == '.nor':
|
|
550
|
+
data = np.loadtxt(fpath, comments="#")
|
|
551
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
552
|
+
if data.shape[1] < 2: raise ValueError("Invalid .nor format")
|
|
553
|
+
x, y = data[:,0], data[:,1]
|
|
554
|
+
e = data[:,2] if data.shape[1] >= 3 else None
|
|
555
|
+
axis_mode = 'energy'
|
|
556
|
+
elif 'chik' in ext:
|
|
557
|
+
data = np.loadtxt(fpath, comments="#")
|
|
558
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
559
|
+
if data.shape[1] < 2: raise ValueError("Invalid .chik data")
|
|
560
|
+
x, y = data[:,0], data[:,1]; e = data[:,2] if data.shape[1] >= 3 else None
|
|
561
|
+
axis_mode = 'k'
|
|
562
|
+
elif 'chir' in ext:
|
|
563
|
+
data = np.loadtxt(fpath, comments="#")
|
|
564
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
565
|
+
if data.shape[1] < 2: raise ValueError("Invalid .chir data")
|
|
566
|
+
x, y = data[:,0], data[:,1]; e = data[:,2] if data.shape[1] >= 3 else None
|
|
567
|
+
axis_mode = 'rft'
|
|
568
|
+
else:
|
|
569
|
+
data = robust_loadtxt_skipheader(fpath)
|
|
570
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
571
|
+
if data.shape[1] < 2: raise ValueError("Invalid 2-column data")
|
|
572
|
+
# Handle --readcol flag to select specific columns
|
|
573
|
+
# Check for extension-specific readcol first, then fall back to general --readcol
|
|
574
|
+
readcol_spec = None
|
|
575
|
+
if hasattr(args, 'readcol_by_ext') and ext in args.readcol_by_ext:
|
|
576
|
+
readcol_spec = args.readcol_by_ext[ext]
|
|
577
|
+
elif args.readcol:
|
|
578
|
+
readcol_spec = args.readcol
|
|
579
|
+
|
|
580
|
+
if readcol_spec:
|
|
581
|
+
x_col, y_col = readcol_spec
|
|
582
|
+
# Convert from 1-indexed to 0-indexed
|
|
583
|
+
x_col_idx = x_col - 1
|
|
584
|
+
y_col_idx = y_col - 1
|
|
585
|
+
if x_col_idx < 0 or x_col_idx >= data.shape[1]:
|
|
586
|
+
raise ValueError(f"X column {x_col} out of range (has {data.shape[1]} columns)")
|
|
587
|
+
if y_col_idx < 0 or y_col_idx >= data.shape[1]:
|
|
588
|
+
raise ValueError(f"Y column {y_col} out of range (has {data.shape[1]} columns)")
|
|
589
|
+
x, y = data[:, x_col_idx], data[:, y_col_idx]
|
|
590
|
+
e = None # Error bars not supported with custom column selection
|
|
591
|
+
else:
|
|
592
|
+
x, y = data[:,0], data[:,1]
|
|
593
|
+
e = data[:,2] if data.shape[1] >= 3 else None
|
|
594
|
+
if ext == '.qye':
|
|
595
|
+
axis_mode = 'Q'
|
|
596
|
+
elif ext == '.gr':
|
|
597
|
+
axis_mode = 'r'
|
|
598
|
+
elif ext == '.nor':
|
|
599
|
+
axis_mode = 'energy'
|
|
600
|
+
elif 'chik' in ext:
|
|
601
|
+
axis_mode = 'k'
|
|
602
|
+
elif 'chir' in ext:
|
|
603
|
+
axis_mode = 'rft'
|
|
604
|
+
elif args.xaxis:
|
|
605
|
+
axis_mode = args.xaxis
|
|
606
|
+
# Print note once per unknown extension type
|
|
607
|
+
if not hasattr(args, '_batch_warned_extensions'):
|
|
608
|
+
args._batch_warned_extensions = set()
|
|
609
|
+
if ext and ext not in args._batch_warned_extensions and ext not in known_axis_ext:
|
|
610
|
+
args._batch_warned_extensions.add(ext)
|
|
611
|
+
print(f" Note: Reading '{ext}' files as 2-column (x, y) data with x-axis = {args.xaxis}")
|
|
612
|
+
else:
|
|
613
|
+
raise ValueError(f"Unknown file type: {fname}. Use --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
|
|
614
|
+
|
|
615
|
+
# Convert to Q if needed
|
|
616
|
+
if axis_mode == 'Q' and ext not in ('.qye', '.gr', '.nor'):
|
|
617
|
+
if args.wl is None:
|
|
618
|
+
axis_mode = '2theta'
|
|
619
|
+
x_plot = x
|
|
620
|
+
else:
|
|
621
|
+
theta_rad = np.radians(x/2)
|
|
622
|
+
x_plot = 4*np.pi*np.sin(theta_rad)/args.wl
|
|
623
|
+
else:
|
|
624
|
+
x_plot = x
|
|
625
|
+
|
|
626
|
+
# Normalize if --norm flag is set
|
|
627
|
+
if getattr(args, 'norm', False):
|
|
628
|
+
if y.size:
|
|
629
|
+
ymin = float(y.min()); ymax = float(y.max())
|
|
630
|
+
span = ymax - ymin
|
|
631
|
+
y_plot = (y - ymin)/span if span > 0 else np.zeros_like(y)
|
|
632
|
+
else:
|
|
633
|
+
y_plot = y
|
|
634
|
+
else:
|
|
635
|
+
y_plot = y.copy()
|
|
636
|
+
|
|
637
|
+
# Plot and save
|
|
638
|
+
fig_b, ax_b = plt.subplots(figsize=(6,4))
|
|
639
|
+
ax_b.plot(x_plot, y_plot, lw=1)
|
|
640
|
+
|
|
641
|
+
# Apply style file if provided via --all flag, otherwise check for per-file style
|
|
642
|
+
applied_style = False
|
|
643
|
+
if style_cfg:
|
|
644
|
+
# Apply global style file from --all flag
|
|
645
|
+
_apply_xy_style(fig_b, ax_b, style_cfg)
|
|
646
|
+
applied_style = True
|
|
647
|
+
else:
|
|
648
|
+
# Check for style file with same base name as data file
|
|
649
|
+
base_name = os.path.splitext(fname)[0]
|
|
650
|
+
for style_ext in ['.bps', '.bpsg', '.bpcfg']:
|
|
651
|
+
style_path = os.path.join(directory, base_name + style_ext)
|
|
652
|
+
if os.path.exists(style_path):
|
|
653
|
+
file_style_cfg = _load_style_file(style_path)
|
|
654
|
+
if file_style_cfg:
|
|
655
|
+
print(f" Applying style from {base_name + style_ext}")
|
|
656
|
+
_apply_xy_style(fig_b, ax_b, file_style_cfg)
|
|
657
|
+
applied_style = True
|
|
658
|
+
break
|
|
659
|
+
|
|
660
|
+
# Apply x-range if specified
|
|
661
|
+
if args.xrange:
|
|
662
|
+
ax_b.set_xlim(args.xrange[0], args.xrange[1])
|
|
663
|
+
|
|
664
|
+
if axis_mode == 'Q':
|
|
665
|
+
ax_b.set_xlabel(r"Q ($\mathrm{\AA}^{-1}$)")
|
|
666
|
+
elif axis_mode == 'r':
|
|
667
|
+
ax_b.set_xlabel("r (Å)")
|
|
668
|
+
elif axis_mode == 'energy':
|
|
669
|
+
ax_b.set_xlabel("Energy (eV)")
|
|
670
|
+
elif axis_mode == 'k':
|
|
671
|
+
ax_b.set_xlabel(r"k ($\mathrm{\AA}^{-1}$)")
|
|
672
|
+
elif axis_mode == 'rft':
|
|
673
|
+
ax_b.set_xlabel("Radial distance (Å)")
|
|
674
|
+
else:
|
|
675
|
+
ax_b.set_xlabel(r"$2\theta\ (\mathrm{deg})$")
|
|
676
|
+
ax_b.set_ylabel("Normalized intensity (a.u.)" if getattr(args, 'norm', False) else "Intensity")
|
|
677
|
+
ax_b.set_title(fname)
|
|
678
|
+
fig_b.subplots_adjust(left=0.18, right=0.97, bottom=0.16, top=0.90)
|
|
679
|
+
# Get output format from args, default to svg
|
|
680
|
+
output_format = getattr(args, 'format', 'svg')
|
|
681
|
+
out_name = os.path.splitext(fname)[0] + f".{output_format}"
|
|
682
|
+
out_path = os.path.join(out_dir, out_name)
|
|
683
|
+
target = _confirm_overwrite(out_path)
|
|
684
|
+
if not target:
|
|
685
|
+
plt.close(fig_b)
|
|
686
|
+
print(f" Skipped {out_name} (user canceled)")
|
|
687
|
+
else:
|
|
688
|
+
# Transparent background for SVG exports
|
|
689
|
+
_, _ext = os.path.splitext(target)
|
|
690
|
+
if _ext.lower() == '.svg':
|
|
691
|
+
# Fix for Affinity Designer/Photo compatibility issues
|
|
692
|
+
# Use 'none' to embed fonts as text (not paths) - prevents phantom labels
|
|
693
|
+
# Set hashsalt to empty to avoid duplicate text elements
|
|
694
|
+
plt.rcParams['svg.fonttype'] = 'none'
|
|
695
|
+
plt.rcParams['svg.hashsalt'] = None
|
|
696
|
+
try:
|
|
697
|
+
_fig_fc = fig_b.get_facecolor()
|
|
698
|
+
except Exception:
|
|
699
|
+
_fig_fc = None
|
|
700
|
+
try:
|
|
701
|
+
_ax_fc = ax_b.get_facecolor()
|
|
702
|
+
except Exception:
|
|
703
|
+
_ax_fc = None
|
|
704
|
+
try:
|
|
705
|
+
if getattr(fig_b, 'patch', None) is not None:
|
|
706
|
+
fig_b.patch.set_alpha(0.0); fig_b.patch.set_facecolor('none')
|
|
707
|
+
if getattr(ax_b, 'patch', None) is not None:
|
|
708
|
+
ax_b.patch.set_alpha(0.0); ax_b.patch.set_facecolor('none')
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
try:
|
|
712
|
+
fig_b.savefig(target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
713
|
+
finally:
|
|
714
|
+
try:
|
|
715
|
+
if _fig_fc is not None and getattr(fig_b, 'patch', None) is not None:
|
|
716
|
+
fig_b.patch.set_alpha(1.0); fig_b.patch.set_facecolor(_fig_fc)
|
|
717
|
+
except Exception:
|
|
718
|
+
pass
|
|
719
|
+
try:
|
|
720
|
+
if _ax_fc is not None and getattr(ax_b, 'patch', None) is not None:
|
|
721
|
+
ax_b.patch.set_alpha(1.0); ax_b.patch.set_facecolor(_ax_fc)
|
|
722
|
+
except Exception:
|
|
723
|
+
pass
|
|
724
|
+
else:
|
|
725
|
+
fig_b.savefig(target, dpi=300)
|
|
726
|
+
plt.close(fig_b)
|
|
727
|
+
print(f" Saved {os.path.basename(target)}")
|
|
728
|
+
except Exception as e:
|
|
729
|
+
print(f" Skipped {fname}: {e}")
|
|
730
|
+
print("Batch processing complete.")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def batch_process_ec(directory: str, args):
|
|
734
|
+
"""Batch process electrochemistry files in a directory.
|
|
735
|
+
|
|
736
|
+
Supports GC (.mpt/.csv), CV (.mpt), dQdV (.csv), and CPC (.mpt/.csv) modes.
|
|
737
|
+
Exports SVG plots to batplot_svg subdirectory.
|
|
738
|
+
|
|
739
|
+
Can apply style/geometry from .bps/.bpsg files using --all flag:
|
|
740
|
+
batplot --all --gc style.bps # Apply style.bps to all .mpt/.csv GC files
|
|
741
|
+
batplot --all --cv style.bpsg # Apply style+geom to all CV files
|
|
742
|
+
batplot --all --dqdv mystyle.bps # Apply style to all dQdV files
|
|
743
|
+
batplot --all --cpc config.bpsg # Apply to all CPC files
|
|
744
|
+
|
|
745
|
+
Note: For GC and CPC modes with .csv files, --mass is not required as the
|
|
746
|
+
capacity data is already in the file. For .mpt files, --mass is required.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
directory: Directory containing EC files
|
|
750
|
+
args: Argument namespace with mode flags (gc, cv, dqdv, cpc), mass, and all
|
|
751
|
+
"""
|
|
752
|
+
print(f"EC Batch mode: scanning {directory}")
|
|
753
|
+
|
|
754
|
+
# Check if --all flag was used with a style file
|
|
755
|
+
style_cfg = None
|
|
756
|
+
style_file_arg = getattr(args, 'all', None)
|
|
757
|
+
if style_file_arg and style_file_arg != 'all':
|
|
758
|
+
# User provided a style file path - resolve it properly
|
|
759
|
+
# Handle absolute paths, relative paths, and paths with/without extensions
|
|
760
|
+
style_path = None
|
|
761
|
+
if os.path.isabs(style_file_arg):
|
|
762
|
+
# Absolute path provided
|
|
763
|
+
style_path = os.path.normpath(style_file_arg)
|
|
764
|
+
else:
|
|
765
|
+
# Relative path - try multiple locations
|
|
766
|
+
# 1. Relative to current working directory
|
|
767
|
+
cwd_path = os.path.normpath(os.path.join(os.getcwd(), style_file_arg))
|
|
768
|
+
# 2. Relative to batch directory
|
|
769
|
+
dir_path = os.path.normpath(os.path.join(directory, style_file_arg))
|
|
770
|
+
|
|
771
|
+
# Try current working directory first (for paths like ./Style/style.bps)
|
|
772
|
+
if os.path.exists(cwd_path) and cwd_path.lower().endswith(('.bps', '.bpsg', '.bpcfg')):
|
|
773
|
+
style_path = cwd_path
|
|
774
|
+
elif os.path.exists(dir_path) and dir_path.lower().endswith(('.bps', '.bpsg', '.bpcfg')):
|
|
775
|
+
style_path = dir_path
|
|
776
|
+
else:
|
|
777
|
+
# Try adding extensions if not present
|
|
778
|
+
for test_path in [cwd_path, dir_path]:
|
|
779
|
+
for ext in ['.bps', '.bpsg', '.bpcfg']:
|
|
780
|
+
if test_path.lower().endswith(ext):
|
|
781
|
+
continue
|
|
782
|
+
test_full = test_path + ext
|
|
783
|
+
if os.path.exists(test_full):
|
|
784
|
+
style_path = test_full
|
|
785
|
+
break
|
|
786
|
+
if style_path:
|
|
787
|
+
break
|
|
788
|
+
|
|
789
|
+
if style_path and os.path.exists(style_path):
|
|
790
|
+
style_cfg = _load_style_file(style_path)
|
|
791
|
+
if style_cfg:
|
|
792
|
+
print(f"Using style file: {os.path.basename(style_path)}")
|
|
793
|
+
else:
|
|
794
|
+
print(f"Warning: Could not find style file '{style_file_arg}'")
|
|
795
|
+
|
|
796
|
+
# Determine which EC mode is active
|
|
797
|
+
mode = None
|
|
798
|
+
if getattr(args, 'gc', False):
|
|
799
|
+
mode = 'gc'
|
|
800
|
+
supported_ext = {'.mpt', '.csv'}
|
|
801
|
+
elif getattr(args, 'cv', False):
|
|
802
|
+
mode = 'cv'
|
|
803
|
+
supported_ext = {'.mpt', '.txt'}
|
|
804
|
+
elif getattr(args, 'dqdv', False):
|
|
805
|
+
mode = 'dqdv'
|
|
806
|
+
supported_ext = {'.csv'}
|
|
807
|
+
elif getattr(args, 'cpc', False):
|
|
808
|
+
mode = 'cpc'
|
|
809
|
+
supported_ext = {'.mpt', '.csv'}
|
|
810
|
+
else:
|
|
811
|
+
print("EC batch mode requires one of: --gc, --cv, --dqdv, or --cpc")
|
|
812
|
+
return
|
|
813
|
+
|
|
814
|
+
from .utils import ensure_subdirectory
|
|
815
|
+
out_dir = ensure_subdirectory('Figures', directory)
|
|
816
|
+
|
|
817
|
+
files = [f for f in sorted(os.listdir(directory))
|
|
818
|
+
if os.path.splitext(f)[1].lower() in supported_ext
|
|
819
|
+
and os.path.isfile(os.path.join(directory, f))]
|
|
820
|
+
|
|
821
|
+
if not files:
|
|
822
|
+
print(f"No {mode.upper()} files found.")
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
print(f"Found {len(files)} {mode.upper()} files. Exporting SVG plots to Figures/")
|
|
826
|
+
|
|
827
|
+
# Enhanced color palette using matplotlib colormaps
|
|
828
|
+
# Start with base colors, then generate more using colormap if needed
|
|
829
|
+
base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
830
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
831
|
+
|
|
832
|
+
def get_color_palette(n_colors):
|
|
833
|
+
"""Generate a color palette with n_colors distinct colors.
|
|
834
|
+
|
|
835
|
+
For large numbers of cycles (>70), uses continuous colormaps to ensure
|
|
836
|
+
all cycles get visually distinct colors.
|
|
837
|
+
"""
|
|
838
|
+
if n_colors <= len(base_colors):
|
|
839
|
+
return base_colors[:n_colors]
|
|
840
|
+
else:
|
|
841
|
+
import matplotlib.cm as cm
|
|
842
|
+
colors = list(base_colors) # Start with base colors
|
|
843
|
+
remaining = n_colors - len(base_colors)
|
|
844
|
+
|
|
845
|
+
if remaining <= 60:
|
|
846
|
+
# Use tab20, tab20b, tab20c for categorical colors (up to 70 total)
|
|
847
|
+
tab20 = cm.get_cmap('tab20')
|
|
848
|
+
tab20b = cm.get_cmap('tab20b')
|
|
849
|
+
tab20c = cm.get_cmap('tab20c')
|
|
850
|
+
|
|
851
|
+
for i in range(remaining):
|
|
852
|
+
cmap_idx = i % 60
|
|
853
|
+
if cmap_idx < 20:
|
|
854
|
+
color = tab20(cmap_idx / 20)
|
|
855
|
+
elif cmap_idx < 40:
|
|
856
|
+
color = tab20b((cmap_idx - 20) / 20)
|
|
857
|
+
else:
|
|
858
|
+
color = tab20c((cmap_idx - 40) / 20)
|
|
859
|
+
hex_color = '#{:02x}{:02x}{:02x}'.format(
|
|
860
|
+
int(color[0]*255), int(color[1]*255), int(color[2]*255))
|
|
861
|
+
if hex_color not in colors:
|
|
862
|
+
colors.append(hex_color)
|
|
863
|
+
if len(colors) >= n_colors:
|
|
864
|
+
break
|
|
865
|
+
else:
|
|
866
|
+
# For >70 cycles, use continuous colormaps for smooth color gradients
|
|
867
|
+
# Combine multiple perceptually uniform colormaps
|
|
868
|
+
cmaps = ['viridis', 'plasma', 'inferno', 'magma', 'cividis',
|
|
869
|
+
'turbo', 'twilight', 'hsv']
|
|
870
|
+
colors_per_map = (remaining + len(cmaps) - 1) // len(cmaps)
|
|
871
|
+
|
|
872
|
+
for cmap_name in cmaps:
|
|
873
|
+
cmap = cm.get_cmap(cmap_name)
|
|
874
|
+
# Sample evenly across the colormap
|
|
875
|
+
for i in range(colors_per_map):
|
|
876
|
+
if len(colors) >= n_colors:
|
|
877
|
+
break
|
|
878
|
+
# Sample from middle 80% of colormap to avoid extreme light/dark
|
|
879
|
+
t = 0.1 + 0.8 * (i / max(colors_per_map - 1, 1))
|
|
880
|
+
color = cmap(t)
|
|
881
|
+
hex_color = '#{:02x}{:02x}{:02x}'.format(
|
|
882
|
+
int(color[0]*255), int(color[1]*255), int(color[2]*255))
|
|
883
|
+
if hex_color not in colors:
|
|
884
|
+
colors.append(hex_color)
|
|
885
|
+
if len(colors) >= n_colors:
|
|
886
|
+
break
|
|
887
|
+
|
|
888
|
+
return colors[:n_colors] # Ensure exact count
|
|
889
|
+
|
|
890
|
+
for fname in files:
|
|
891
|
+
fpath = os.path.join(directory, fname)
|
|
892
|
+
ext = os.path.splitext(fname)[1].lower()
|
|
893
|
+
|
|
894
|
+
try:
|
|
895
|
+
fig_b, ax_b = plt.subplots(figsize=(6, 4))
|
|
896
|
+
|
|
897
|
+
# ---- GC Mode ----
|
|
898
|
+
if mode == 'gc':
|
|
899
|
+
if ext == '.mpt':
|
|
900
|
+
mass_mg = getattr(args, 'mass', None)
|
|
901
|
+
if mass_mg is None:
|
|
902
|
+
print(f" Skipped {fname}: GC mode (.mpt) requires --mass parameter")
|
|
903
|
+
plt.close(fig_b)
|
|
904
|
+
continue
|
|
905
|
+
specific_capacity, voltage, cycle_numbers, charge_mask, discharge_mask = \
|
|
906
|
+
read_mpt_file(fpath, mode='gc', mass_mg=mass_mg)
|
|
907
|
+
cap_x = specific_capacity
|
|
908
|
+
x_label = r'Specific Capacity (mAh g$^{-1}$)'
|
|
909
|
+
elif ext == '.csv':
|
|
910
|
+
cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = \
|
|
911
|
+
read_ec_csv_file(fpath, prefer_specific=True)
|
|
912
|
+
x_label = r'Specific Capacity (mAh g$^{-1}$)'
|
|
913
|
+
else:
|
|
914
|
+
raise ValueError(f"Unsupported file type for GC: {ext}")
|
|
915
|
+
|
|
916
|
+
# Plot cycles
|
|
917
|
+
if cycle_numbers is not None:
|
|
918
|
+
cyc_int_raw = np.array(np.rint(cycle_numbers), dtype=int)
|
|
919
|
+
if cyc_int_raw.size:
|
|
920
|
+
min_c = int(np.min(cyc_int_raw))
|
|
921
|
+
shift = 1 - min_c if min_c <= 0 else 0
|
|
922
|
+
cyc_int = cyc_int_raw + shift
|
|
923
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int))
|
|
924
|
+
else:
|
|
925
|
+
cycles_present = [1]
|
|
926
|
+
else:
|
|
927
|
+
cycles_present = [1]
|
|
928
|
+
|
|
929
|
+
# Generate color palette for the number of cycles
|
|
930
|
+
cycle_colors = get_color_palette(len(cycles_present))
|
|
931
|
+
|
|
932
|
+
for idx, cyc in enumerate(cycles_present): # Plot all cycles
|
|
933
|
+
if cycle_numbers is not None:
|
|
934
|
+
mask_c = (cyc_int == cyc) & charge_mask
|
|
935
|
+
mask_d = (cyc_int == cyc) & discharge_mask
|
|
936
|
+
else:
|
|
937
|
+
mask_c = charge_mask
|
|
938
|
+
mask_d = discharge_mask
|
|
939
|
+
|
|
940
|
+
color = cycle_colors[idx]
|
|
941
|
+
|
|
942
|
+
# Plot charge and discharge with the same color and label
|
|
943
|
+
plotted = False
|
|
944
|
+
if np.any(mask_c):
|
|
945
|
+
ax_b.plot(cap_x[mask_c], voltage[mask_c], '-',
|
|
946
|
+
color=color, linewidth=1.5, alpha=0.8, label=str(cyc))
|
|
947
|
+
plotted = True
|
|
948
|
+
if np.any(mask_d):
|
|
949
|
+
if plotted:
|
|
950
|
+
# Don't add another label for discharge
|
|
951
|
+
ax_b.plot(cap_x[mask_d], voltage[mask_d], '-',
|
|
952
|
+
color=color, linewidth=1.5, alpha=0.8)
|
|
953
|
+
else:
|
|
954
|
+
ax_b.plot(cap_x[mask_d], voltage[mask_d], '-',
|
|
955
|
+
color=color, linewidth=1.5, alpha=0.8, label=str(cyc))
|
|
956
|
+
|
|
957
|
+
ax_b.set_xlabel(x_label)
|
|
958
|
+
ax_b.set_ylabel('Voltage (V)')
|
|
959
|
+
ax_b.set_title(f"{fname}")
|
|
960
|
+
legend = ax_b.legend(loc='best', fontsize='small', framealpha=0.8, title='Cycle')
|
|
961
|
+
legend.get_title().set_fontsize('small')
|
|
962
|
+
|
|
963
|
+
# ---- CV Mode ----
|
|
964
|
+
elif mode == 'cv':
|
|
965
|
+
if ext == '.txt':
|
|
966
|
+
from .readers import read_biologic_txt_file
|
|
967
|
+
voltage, current, cycles = read_biologic_txt_file(fpath, mode='cv')
|
|
968
|
+
elif ext == '.mpt':
|
|
969
|
+
voltage, current, cycles = read_mpt_file(fpath, mode='cv')
|
|
970
|
+
else:
|
|
971
|
+
raise ValueError("CV mode requires .mpt or .txt file")
|
|
972
|
+
|
|
973
|
+
cyc_int_raw = np.array(np.rint(cycles), dtype=int)
|
|
974
|
+
if cyc_int_raw.size:
|
|
975
|
+
min_c = int(np.min(cyc_int_raw))
|
|
976
|
+
shift = 1 - min_c if min_c <= 0 else 0
|
|
977
|
+
cyc_int = cyc_int_raw + shift
|
|
978
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int))
|
|
979
|
+
else:
|
|
980
|
+
cycles_present = [1]
|
|
981
|
+
|
|
982
|
+
# Generate color palette for the number of cycles
|
|
983
|
+
cycle_colors = get_color_palette(len(cycles_present))
|
|
984
|
+
|
|
985
|
+
for idx, cyc in enumerate(cycles_present): # Plot all cycles
|
|
986
|
+
mask = (cyc_int == cyc)
|
|
987
|
+
mask_idx = np.where(mask)[0]
|
|
988
|
+
if mask_idx.size >= 2:
|
|
989
|
+
color = cycle_colors[idx]
|
|
990
|
+
ax_b.plot(voltage[mask], current[mask], '-',
|
|
991
|
+
color=color, linewidth=1.5, alpha=0.8, label=str(cyc))
|
|
992
|
+
|
|
993
|
+
ax_b.set_xlabel('Voltage (V)')
|
|
994
|
+
ax_b.set_ylabel('Current (mA)')
|
|
995
|
+
ax_b.set_title(f"{fname}")
|
|
996
|
+
legend = ax_b.legend(loc='best', fontsize='small', framealpha=0.8, title='Cycle')
|
|
997
|
+
legend.get_title().set_fontsize('small')
|
|
998
|
+
|
|
999
|
+
# ---- dQdV Mode ----
|
|
1000
|
+
elif mode == 'dqdv':
|
|
1001
|
+
if ext != '.csv':
|
|
1002
|
+
raise ValueError("dQdV mode requires .csv file")
|
|
1003
|
+
|
|
1004
|
+
# Read dQdV data with cycle information
|
|
1005
|
+
voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = \
|
|
1006
|
+
read_ec_csv_dqdv_file(fpath, prefer_specific=True)
|
|
1007
|
+
|
|
1008
|
+
# Process cycles similar to GC mode
|
|
1009
|
+
if cycles is not None and cycles.size > 0:
|
|
1010
|
+
cyc_int_raw = np.array(np.rint(cycles), dtype=int)
|
|
1011
|
+
if cyc_int_raw.size:
|
|
1012
|
+
min_c = int(np.min(cyc_int_raw))
|
|
1013
|
+
shift = 1 - min_c if min_c <= 0 else 0
|
|
1014
|
+
cyc_int = cyc_int_raw + shift
|
|
1015
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int))
|
|
1016
|
+
else:
|
|
1017
|
+
cycles_present = [1]
|
|
1018
|
+
else:
|
|
1019
|
+
cycles_present = [1]
|
|
1020
|
+
|
|
1021
|
+
# Generate color palette for the number of cycles
|
|
1022
|
+
cycle_colors = get_color_palette(len(cycles_present))
|
|
1023
|
+
|
|
1024
|
+
# Plot each cycle
|
|
1025
|
+
for idx, cyc in enumerate(cycles_present):
|
|
1026
|
+
if cycles is not None:
|
|
1027
|
+
mask_c = (cyc_int == cyc) & charge_mask
|
|
1028
|
+
mask_d = (cyc_int == cyc) & discharge_mask
|
|
1029
|
+
else:
|
|
1030
|
+
mask_c = charge_mask
|
|
1031
|
+
mask_d = discharge_mask
|
|
1032
|
+
|
|
1033
|
+
color = cycle_colors[idx]
|
|
1034
|
+
|
|
1035
|
+
# Plot charge and discharge with the same color and label
|
|
1036
|
+
plotted = False
|
|
1037
|
+
if np.any(mask_c):
|
|
1038
|
+
ax_b.plot(voltage[mask_c], dqdv[mask_c], '-',
|
|
1039
|
+
color=color, linewidth=1.5, alpha=0.8, label=str(cyc))
|
|
1040
|
+
plotted = True
|
|
1041
|
+
if np.any(mask_d):
|
|
1042
|
+
if plotted:
|
|
1043
|
+
# Don't add another label for discharge
|
|
1044
|
+
ax_b.plot(voltage[mask_d], dqdv[mask_d], '-',
|
|
1045
|
+
color=color, linewidth=1.5, alpha=0.8)
|
|
1046
|
+
else:
|
|
1047
|
+
ax_b.plot(voltage[mask_d], dqdv[mask_d], '-',
|
|
1048
|
+
color=color, linewidth=1.5, alpha=0.8, label=str(cyc))
|
|
1049
|
+
|
|
1050
|
+
ax_b.set_xlabel('Voltage (V)')
|
|
1051
|
+
ax_b.set_ylabel(y_label)
|
|
1052
|
+
ax_b.set_title(f"{fname}")
|
|
1053
|
+
legend = ax_b.legend(loc='best', fontsize='small', framealpha=0.8, title='Cycle')
|
|
1054
|
+
legend.get_title().set_fontsize('small')
|
|
1055
|
+
|
|
1056
|
+
# ---- CPC Mode ----
|
|
1057
|
+
elif mode == 'cpc':
|
|
1058
|
+
if ext == '.mpt':
|
|
1059
|
+
mass_mg = getattr(args, 'mass', None)
|
|
1060
|
+
if mass_mg is None:
|
|
1061
|
+
print(f" Skipped {fname}: CPC mode (.mpt) requires --mass parameter")
|
|
1062
|
+
plt.close(fig_b)
|
|
1063
|
+
continue
|
|
1064
|
+
cyc_nums, cap_charge, cap_discharge, eff = \
|
|
1065
|
+
read_mpt_file(fpath, mode='cpc', mass_mg=mass_mg)
|
|
1066
|
+
x_label = r'Specific Capacity (mAh g$^{-1}$)'
|
|
1067
|
+
elif ext == '.csv':
|
|
1068
|
+
# For CSV CPC, read as GC-like data
|
|
1069
|
+
cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = \
|
|
1070
|
+
read_ec_csv_file(fpath, prefer_specific=True)
|
|
1071
|
+
# Plot capacity vs cycle number
|
|
1072
|
+
if cycle_numbers is not None:
|
|
1073
|
+
cyc_int_raw = np.array(np.rint(cycle_numbers), dtype=int)
|
|
1074
|
+
if cyc_int_raw.size:
|
|
1075
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int_raw))
|
|
1076
|
+
# Calculate capacity per cycle
|
|
1077
|
+
cap_charge = []
|
|
1078
|
+
cap_discharge = []
|
|
1079
|
+
for cyc in cycles_present:
|
|
1080
|
+
mask_c = (cyc_int_raw == cyc) & charge_mask
|
|
1081
|
+
mask_d = (cyc_int_raw == cyc) & discharge_mask
|
|
1082
|
+
cap_charge.append(np.max(cap_x[mask_c]) if np.any(mask_c) else 0)
|
|
1083
|
+
cap_discharge.append(np.max(cap_x[mask_d]) if np.any(mask_d) else 0)
|
|
1084
|
+
cyc_nums = np.array(cycles_present)
|
|
1085
|
+
cap_charge = np.array(cap_charge)
|
|
1086
|
+
cap_discharge = np.array(cap_discharge)
|
|
1087
|
+
else:
|
|
1088
|
+
cyc_nums = np.array([1])
|
|
1089
|
+
cap_charge = np.array([0])
|
|
1090
|
+
cap_discharge = np.array([0])
|
|
1091
|
+
else:
|
|
1092
|
+
cyc_nums = np.array([1])
|
|
1093
|
+
cap_charge = np.array([0])
|
|
1094
|
+
cap_discharge = np.array([0])
|
|
1095
|
+
x_label = r'Specific Capacity (mAh g$^{-1}$)'
|
|
1096
|
+
else:
|
|
1097
|
+
raise ValueError(f"Unsupported file type for CPC: {ext}")
|
|
1098
|
+
|
|
1099
|
+
# Plot CPC data
|
|
1100
|
+
ax_b.plot(cyc_nums, cap_charge, 'o-', color='#1f77b4',
|
|
1101
|
+
linewidth=1.5, markersize=4, label='Charge', alpha=0.8)
|
|
1102
|
+
ax_b.plot(cyc_nums, cap_discharge, 's-', color='#ff7f0e',
|
|
1103
|
+
linewidth=1.5, markersize=4, label='Discharge', alpha=0.8)
|
|
1104
|
+
ax_b.set_xlabel('Cycle Number')
|
|
1105
|
+
ax_b.set_ylabel(x_label)
|
|
1106
|
+
ax_b.legend()
|
|
1107
|
+
ax_b.set_title(f"{fname}")
|
|
1108
|
+
|
|
1109
|
+
# Apply style/geometry if provided via --all flag
|
|
1110
|
+
if style_cfg:
|
|
1111
|
+
try:
|
|
1112
|
+
_apply_ec_style(fig_b, ax_b, style_cfg)
|
|
1113
|
+
except Exception as e:
|
|
1114
|
+
print(f" Warning: Could not apply style to {fname}: {e}")
|
|
1115
|
+
|
|
1116
|
+
# Adjust layout and save
|
|
1117
|
+
fig_b.subplots_adjust(left=0.18, right=0.97, bottom=0.16, top=0.90)
|
|
1118
|
+
# Get output format from args, default to svg
|
|
1119
|
+
output_format = getattr(args, 'format', 'svg')
|
|
1120
|
+
out_name = os.path.splitext(fname)[0] + f"_{mode}.{output_format}"
|
|
1121
|
+
out_path = os.path.join(out_dir, out_name)
|
|
1122
|
+
|
|
1123
|
+
target = _confirm_overwrite(out_path)
|
|
1124
|
+
if not target:
|
|
1125
|
+
plt.close(fig_b)
|
|
1126
|
+
print(f" Skipped {out_name} (user canceled)")
|
|
1127
|
+
else:
|
|
1128
|
+
# Transparent background for SVG
|
|
1129
|
+
_, _ext = os.path.splitext(target)
|
|
1130
|
+
if _ext.lower() == '.svg':
|
|
1131
|
+
try:
|
|
1132
|
+
_fig_fc = fig_b.get_facecolor()
|
|
1133
|
+
except Exception:
|
|
1134
|
+
_fig_fc = None
|
|
1135
|
+
try:
|
|
1136
|
+
_ax_fc = ax_b.get_facecolor()
|
|
1137
|
+
except Exception:
|
|
1138
|
+
_ax_fc = None
|
|
1139
|
+
try:
|
|
1140
|
+
if getattr(fig_b, 'patch', None) is not None:
|
|
1141
|
+
fig_b.patch.set_alpha(0.0)
|
|
1142
|
+
fig_b.patch.set_facecolor('none')
|
|
1143
|
+
if getattr(ax_b, 'patch', None) is not None:
|
|
1144
|
+
ax_b.patch.set_alpha(0.0)
|
|
1145
|
+
ax_b.patch.set_facecolor('none')
|
|
1146
|
+
except Exception:
|
|
1147
|
+
pass
|
|
1148
|
+
try:
|
|
1149
|
+
fig_b.savefig(target, dpi=300, transparent=True,
|
|
1150
|
+
facecolor='none', edgecolor='none')
|
|
1151
|
+
finally:
|
|
1152
|
+
try:
|
|
1153
|
+
if _fig_fc is not None and getattr(fig_b, 'patch', None) is not None:
|
|
1154
|
+
fig_b.patch.set_alpha(1.0)
|
|
1155
|
+
fig_b.patch.set_facecolor(_fig_fc)
|
|
1156
|
+
except Exception:
|
|
1157
|
+
pass
|
|
1158
|
+
try:
|
|
1159
|
+
if _ax_fc is not None and getattr(ax_b, 'patch', None) is not None:
|
|
1160
|
+
ax_b.patch.set_alpha(1.0)
|
|
1161
|
+
ax_b.patch.set_facecolor(_ax_fc)
|
|
1162
|
+
except Exception:
|
|
1163
|
+
pass
|
|
1164
|
+
else:
|
|
1165
|
+
fig_b.savefig(target, dpi=300)
|
|
1166
|
+
plt.close(fig_b)
|
|
1167
|
+
print(f" Saved {os.path.basename(target)}")
|
|
1168
|
+
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
plt.close(fig_b)
|
|
1171
|
+
print(f" Skipped {fname}: {e}")
|
|
1172
|
+
|
|
1173
|
+
print(f"EC batch processing complete ({mode.upper()} mode).")
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
__all__ = ["batch_process", "batch_process_ec"]
|