batplot 1.8.0__py3-none-any.whl → 1.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +5 -3
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +96 -3
  5. batplot/electrochem_interactive.py +28 -0
  6. batplot/interactive.py +18 -2
  7. batplot/modes.py +12 -12
  8. batplot/operando.py +2 -0
  9. batplot/operando_ec_interactive.py +112 -11
  10. batplot/session.py +35 -1
  11. batplot/utils.py +40 -0
  12. batplot/version_check.py +85 -6
  13. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  14. batplot-1.8.2.dist-info/RECORD +75 -0
  15. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.0.dist-info/RECORD +0 -52
  40. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.0.dist-info → batplot-1.8.2.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"]