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.

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batch.py +23 -0
  4. batplot/batplot.py +101 -12
  5. batplot/cpc_interactive.py +25 -3
  6. batplot/electrochem_interactive.py +20 -4
  7. batplot/interactive.py +19 -15
  8. batplot/modes.py +12 -12
  9. batplot/operando_ec_interactive.py +4 -4
  10. batplot/session.py +218 -0
  11. batplot/style.py +21 -2
  12. batplot/version_check.py +1 -1
  13. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
  14. batplot-1.8.3.dist-info/RECORD +75 -0
  15. {batplot-1.8.1.dist-info → batplot-1.8.3.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.1.dist-info/RECORD +0 -52
  40. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,3589 @@
1
+ """batplot - Interactive plotting for 1D, electrochemistry and operando contour plots.
2
+ It is designed for researchers working on materials science and electrochemistry, aiming to speed up the plotting process.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ # Import all dependencies at module level
8
+ from .electrochem_interactive import electrochem_interactive_menu
9
+ from .args import parse_args as _bp_parse_args
10
+ from .interactive import interactive_menu
11
+ from .batch import batch_process, batch_process_ec
12
+ from .converters import convert_to_qye
13
+ from .session import (
14
+ dump_session as _bp_dump_session,
15
+ load_ec_session,
16
+ load_operando_session,
17
+ load_cpc_session,
18
+ _apply_axes_bbox as _session_apply_axes_bbox,
19
+ )
20
+ from .operando import plot_operando_folder
21
+ from .plotting import update_labels
22
+ from .utils import _confirm_overwrite, normalize_label_text
23
+ from .readers import (
24
+ read_csv_file,
25
+ read_fullprof_rowwise,
26
+ robust_loadtxt_skipheader,
27
+ read_gr_file,
28
+ read_mpt_file,
29
+ read_ec_csv_file,
30
+ read_ec_csv_dqdv_file,
31
+ read_mpt_dqdv_file,
32
+ read_csv_time_voltage,
33
+ read_mpt_time_voltage,
34
+ read_cs_b_csv_file,
35
+ is_cs_b_format,
36
+ _load_csv_header_and_rows,
37
+ )
38
+ from .cif import (
39
+ simulate_cif_pattern_Q,
40
+ cif_reflection_positions,
41
+ list_reflections_with_hkl,
42
+ build_hkl_label_map_from_list,
43
+ )
44
+ from .ui import (
45
+ apply_font_changes as _ui_apply_font_changes,
46
+ sync_fonts as _ui_sync_fonts,
47
+ position_top_xlabel as _ui_position_top_xlabel,
48
+ position_right_ylabel as _ui_position_right_ylabel,
49
+ update_tick_visibility as _ui_update_tick_visibility,
50
+ ensure_text_visibility as _ui_ensure_text_visibility,
51
+ resize_plot_frame as _ui_resize_plot_frame,
52
+ resize_canvas as _ui_resize_canvas,
53
+ )
54
+ from .style import (
55
+ print_style_info as _bp_print_style_info,
56
+ export_style_config as _bp_export_style_config,
57
+ apply_style_config as _bp_apply_style_config,
58
+ )
59
+
60
+ import numpy as np
61
+ import sys
62
+ import os
63
+ import pickle
64
+ import json
65
+ import random
66
+ import argparse
67
+ import re
68
+ import matplotlib as _mpl
69
+ import matplotlib.pyplot as plt
70
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
71
+
72
+ # Try to import optional interactive menus
73
+ try:
74
+ from .operando_ec_interactive import operando_ec_interactive_menu
75
+ except ImportError:
76
+ operando_ec_interactive_menu = None
77
+
78
+ try:
79
+ from .cpc_interactive import cpc_interactive_menu, _generate_similar_color
80
+ except ImportError:
81
+ cpc_interactive_menu = None
82
+ # Fallback function if import fails
83
+ def _generate_similar_color(base_color):
84
+ """Generate a similar but distinguishable color for discharge from charge color."""
85
+ try:
86
+ from matplotlib.colors import to_rgb, rgb_to_hsv, hsv_to_rgb
87
+ rgb = to_rgb(base_color)
88
+ hsv = rgb_to_hsv(rgb)
89
+ h, s, v = hsv
90
+ h_new = (h + 0.04) % 1.0
91
+ s_new = max(0.3, s * 0.85)
92
+ v_new = max(0.4, v * 0.9)
93
+ rgb_new = hsv_to_rgb([h_new, s_new, v_new])
94
+ # Convert numpy array to tuple to avoid truth value ambiguity
95
+ if hasattr(rgb_new, 'tolist'):
96
+ return tuple(rgb_new.tolist())
97
+ return tuple(rgb_new)
98
+ except Exception:
99
+ try:
100
+ from matplotlib.colors import to_rgb
101
+ rgb = to_rgb(base_color)
102
+ return tuple(max(0, c * 0.7) for c in rgb)
103
+ except Exception:
104
+ return base_color
105
+
106
+ # Global state variables (used by interactive menus and style system)
107
+ keep_canvas_fixed = False
108
+
109
+
110
+ ALLFILES_KNOWN_EXTENSIONS = {'.xye', '.xy', '.qye', '.dat', '.csv', '.gr', '.nor', '.chik', '.chir', '.txt', '.mpt'}
111
+ ALLFILES_EXCLUDED_EXTENSIONS = {'.cif', '.pkl', '.py', '.md', '.json', '.yml', '.yaml', '.sh', '.bat'}
112
+
113
+
114
+ def _natural_sort_key(filename: str) -> list:
115
+ """Generate a natural sorting key for filenames with numbers.
116
+
117
+ Converts 'file_10.xy' to ['file_', 10, '.xy'] so numerical parts are sorted numerically.
118
+ This ensures file_2.xy comes before file_10.xy (natural order).
119
+ """
120
+ parts = []
121
+ for match in re.finditer(r'(\d+|\D+)', filename):
122
+ text = match.group(0)
123
+ if text.isdigit():
124
+ parts.append(int(text))
125
+ else:
126
+ parts.append(text.lower())
127
+ return parts
128
+
129
+
130
+ def _prepare_allfiles_directory(target_dir: str, args, use_relative_paths: bool = False,
131
+ allowed_exts: set[str] | None = None) -> None:
132
+ """Populate args.files with data files under target_dir (optionally filtered by extension)."""
133
+ all_xy_files = []
134
+ unknown_ext_files = [] if allowed_exts is None else None
135
+
136
+ try:
137
+ entries = sorted(os.listdir(target_dir), key=_natural_sort_key)
138
+ except Exception as exc:
139
+ print(f"Failed to list directory '{target_dir}': {exc}")
140
+ exit(1)
141
+
142
+ for f in entries:
143
+ full_path = os.path.join(target_dir, f)
144
+ if not os.path.isfile(full_path):
145
+ continue
146
+ ext = os.path.splitext(f)[1].lower()
147
+ if ext in ALLFILES_EXCLUDED_EXTENSIONS or not ext:
148
+ continue
149
+ if allowed_exts is not None:
150
+ if ext not in allowed_exts:
151
+ continue
152
+ else:
153
+ # Default mode: keep unknown types but track for warning
154
+ if ext not in ALLFILES_KNOWN_EXTENSIONS and unknown_ext_files is not None:
155
+ unknown_ext_files.append(f)
156
+ store_path = f if use_relative_paths else full_path
157
+ all_xy_files.append(store_path)
158
+
159
+ if not all_xy_files:
160
+ if allowed_exts:
161
+ ext_list = ", ".join(sorted(allowed_exts))
162
+ print(f"No {ext_list} files found in directory: {target_dir}")
163
+ else:
164
+ print(f"No data files found in directory: {target_dir}")
165
+ exit(1)
166
+
167
+ if allowed_exts is None and unknown_ext_files:
168
+ print(f"Warning: Found {len(unknown_ext_files)} file(s) with unknown extension(s):")
169
+ for uf in unknown_ext_files[:5]:
170
+ print(f" - {uf}")
171
+ if len(unknown_ext_files) > 5:
172
+ print(f" ... and {len(unknown_ext_files) - 5} more")
173
+ print("These will be read as 2-column (x, y) data.")
174
+ if not args.xaxis:
175
+ print("Tip: Use --xaxis to specify the x-axis type (e.g., --xaxis 2theta, --xaxis Q, --xaxis r)")
176
+
177
+ print(f"Found {len(all_xy_files)} files to plot together")
178
+ args.files = all_xy_files
179
+
180
+
181
+ def _maybe_expand_allfiles_argument(args, ec_mode_active: bool = False) -> None:
182
+ """Handle 'allfiles' argument appearing anywhere by expanding directory contents."""
183
+ if ec_mode_active or not args.files:
184
+ return
185
+ token_info = []
186
+ non_token_entries = []
187
+ for original in args.files:
188
+ lower = original.lower()
189
+ if lower.startswith('all') and lower.endswith('files'):
190
+ middle = lower[3:-5]
191
+ token_info.append((original, middle))
192
+ else:
193
+ non_token_entries.append(original)
194
+ if not token_info:
195
+ return
196
+ if len(token_info) > 1:
197
+ print("Specify only one all*files token (e.g., allfiles or allxyfiles) at a time.")
198
+ exit(1)
199
+ _, middle = token_info[0]
200
+ if len(non_token_entries) > 1:
201
+ print("When using all*files tokens, provide zero or one directory argument.")
202
+ exit(1)
203
+ if middle:
204
+ ext = f".{middle}"
205
+ if ext not in ALLFILES_KNOWN_EXTENSIONS:
206
+ allowed = ", ".join(sorted(e.strip('.') for e in ALLFILES_KNOWN_EXTENSIONS))
207
+ print(f"Unknown all-files token 'all{middle}files'. Allowed extensions: {allowed}")
208
+ exit(1)
209
+ allowed_exts = {ext}
210
+ else:
211
+ allowed_exts = None
212
+ if len(non_token_entries) == 1:
213
+ dir_arg = non_token_entries[0]
214
+ if not os.path.isdir(dir_arg):
215
+ print(f"Directory not found: {dir_arg}")
216
+ exit(1)
217
+ target_dir = os.path.abspath(dir_arg)
218
+ use_relative = False
219
+ else:
220
+ target_dir = os.getcwd()
221
+ use_relative = True
222
+ _prepare_allfiles_directory(target_dir, args, use_relative_paths=use_relative,
223
+ allowed_exts=allowed_exts)
224
+
225
+
226
+ def batplot_main() -> int:
227
+ """
228
+ Main entry point for batplot CLI.
229
+
230
+ This is the central routing function that:
231
+ 1. Parses command-line arguments
232
+ 2. Determines which mode to use (XY, EC, Operando, Batch, etc.)
233
+ 3. Routes to the appropriate handler function
234
+ 4. Handles errors and returns exit codes
235
+
236
+ HOW ROUTING WORKS:
237
+ -----------------
238
+ batplot supports multiple modes, determined by command-line flags:
239
+
240
+ XY MODE (default):
241
+ batplot file1.xy file2.xy → Normal XY plotting
242
+ batplot allfiles → Plot all files together
243
+ batplot --all → Batch mode (separate files)
244
+
245
+ EC MODES (electrochemistry):
246
+ batplot --gc file.mpt --mass 7.0 → Galvanostatic cycling
247
+ batplot --cv file.mpt → Cyclic voltammetry
248
+ batplot --dqdv file.csv → Differential capacity
249
+ batplot --cpc file.csv → Capacity per cycle
250
+
251
+ OPERANDO MODE:
252
+ batplot --operando folder/ → Contour plot from folder
253
+
254
+ BATCH MODES:
255
+ batplot --all → Batch XY mode
256
+ batplot --gc --all --mass 7.0 → Batch EC mode
257
+
258
+ CONVERSION:
259
+ batplot --convert file.xy --wl 1.54 → Convert 2θ to Q
260
+
261
+ The function checks flags in priority order and routes accordingly.
262
+
263
+ Returns:
264
+ Exit code: 0 for success, non-zero for error
265
+ (Follows Unix convention: 0 = success, non-zero = error)
266
+ """
267
+ # ====================================================================
268
+ # STEP 1: PARSE COMMAND-LINE ARGUMENTS
269
+ # ====================================================================
270
+ # Parse all command-line arguments into a namespace object.
271
+ # This includes files, flags (--gc, --cv, etc.), and options (--mass, --wl, etc.)
272
+ # ====================================================================
273
+ args = _bp_parse_args()
274
+
275
+ # ====================================================================
276
+ # STEP 2: VALIDATE INPUT
277
+ # ====================================================================
278
+ # Check if user provided any input (files or special flags).
279
+ # If nothing provided, show help message and exit gracefully.
280
+ # ====================================================================
281
+
282
+ # Check for special flags that don't require file arguments
283
+ # These modes can work without explicit file arguments (e.g., --all scans directory)
284
+ has_special_flag = any([
285
+ getattr(args, 'gc', False), # Galvanostatic cycling mode
286
+ getattr(args, 'cv', False), # Cyclic voltammetry mode
287
+ getattr(args, 'dqdv', False), # Differential capacity mode
288
+ getattr(args, 'cpc', False), # Capacity per cycle mode
289
+ getattr(args, 'operando', False), # Operando contour mode
290
+ getattr(args, 'all', None) is not None, # Batch mode flag
291
+ getattr(args, 'convert', None) is not None, # Conversion mode
292
+ ])
293
+
294
+ # If no files AND no special flags, nothing to do
295
+ if not args.files and not has_special_flag:
296
+ print("No input provided, nothing to do.")
297
+ print("Use 'batplot -h' for CLI help or 'batplot -m' to open the txt manual.")
298
+ return 0 # Exit successfully (not an error, just nothing to do)
299
+
300
+ # ====================================================================
301
+ # STEP 3: ROUTE TO APPROPRIATE MODE HANDLER
302
+ # ====================================================================
303
+ # Check flags in priority order and route to corresponding handler.
304
+ # Priority matters: some modes are checked before others.
305
+ # ====================================================================
306
+
307
+ # ====================================================================
308
+ # EC BATCH MODE (HIGHEST PRIORITY)
309
+ # ====================================================================
310
+ # If any EC mode is active AND user specified batch processing,
311
+ # route to EC batch handler (processes all EC files in directory).
312
+ #
313
+ # EC batch mode examples:
314
+ # batplot --gc --all --mass 7.0 → Process all .mpt/.csv files
315
+ # batplot --cv --all → Process all .mpt/.txt files
316
+ # batplot --gc all --mass 7.0 → Same as above (alternative syntax)
317
+ # batplot --gc /path/to/folder --mass 7 → Process specific directory
318
+ # ====================================================================
319
+
320
+ # Check if any EC mode is active
321
+ ec_mode_active = any([
322
+ getattr(args, 'gc', False), # Galvanostatic cycling
323
+ getattr(args, 'cv', False), # Cyclic voltammetry
324
+ getattr(args, 'dqdv', False), # Differential capacity
325
+ getattr(args, 'cpc', False) # Capacity per cycle
326
+ ])
327
+
328
+ # Check for --all flag (explicit batch mode)
329
+ if ec_mode_active and getattr(args, 'all', None) is not None:
330
+ # Process all EC files in current directory
331
+ batch_process_ec(os.getcwd(), args)
332
+ exit() # Exit after batch processing (don't continue to other modes)
333
+
334
+ # Check for 'all' as file argument or directory path
335
+ if ec_mode_active and len(args.files) == 1:
336
+ sole = args.files[0]
337
+ if sole.lower() == 'all':
338
+ # User typed 'all' as file argument (alternative syntax)
339
+ batch_process_ec(os.getcwd(), args)
340
+ exit()
341
+ elif os.path.isdir(sole):
342
+ # User provided directory path
343
+ batch_process_ec(os.path.abspath(sole), args)
344
+ exit()
345
+
346
+ # --- CV mode: plot voltage vs current for each cycle from .mpt ---
347
+ if getattr(args, 'cv', False):
348
+ import os as _os
349
+ import matplotlib.pyplot as _plt
350
+
351
+ # Separate style files from data files
352
+ data_files = []
353
+ style_file_path = None
354
+ for f in args.files:
355
+ ext = os.path.splitext(f)[1].lower()
356
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
357
+ if style_file_path is None:
358
+ style_file_path = f
359
+ else:
360
+ print(f"Warning: Multiple style files provided, using first: {style_file_path}")
361
+ else:
362
+ data_files.append(f)
363
+
364
+ if not data_files:
365
+ print("CV mode: no data files found (only style files provided).")
366
+ exit(1)
367
+
368
+ # Load style file if provided
369
+ style_cfg = None
370
+ if style_file_path:
371
+ if not os.path.isfile(style_file_path):
372
+ print(f"Warning: Style file not found: {style_file_path}")
373
+ else:
374
+ try:
375
+ with open(style_file_path, 'r', encoding='utf-8') as f:
376
+ style_cfg = json.load(f)
377
+ print(f"Using style file: {os.path.basename(style_file_path)}")
378
+ except Exception as e:
379
+ print(f"Warning: Could not load style file {style_file_path}: {e}")
380
+
381
+ # Process each data file
382
+ from .utils import ensure_subdirectory
383
+ out_dir = None
384
+ if len(data_files) > 1 and (args.savefig or args.out):
385
+ # Multiple files: create output directory
386
+ out_dir = ensure_subdirectory('Figures', os.getcwd())
387
+
388
+ for ec_file in data_files:
389
+ if not _os.path.isfile(ec_file):
390
+ print(f"File not found: {ec_file}")
391
+ continue
392
+ try:
393
+ # Support both .mpt and .txt formats
394
+ if ec_file.lower().endswith('.txt'):
395
+ from .readers import read_biologic_txt_file
396
+ voltage, current, cycles = read_biologic_txt_file(ec_file, mode='cv')
397
+ else:
398
+ voltage, current, cycles = read_mpt_file(ec_file, mode='cv')
399
+ # Normalize cycle indices to start at 1
400
+ # Find the first cycle with at least 2 data points (needed for plotting)
401
+ cyc_int_raw = np.array(np.rint(cycles), dtype=int)
402
+ if cyc_int_raw.size:
403
+ unique_cycles_raw = np.unique(cyc_int_raw)
404
+ valid_min_c = None
405
+ for c in sorted(unique_cycles_raw):
406
+ if np.sum(cyc_int_raw == c) >= 2:
407
+ valid_min_c = int(c)
408
+ break
409
+
410
+ if valid_min_c is not None:
411
+ shift = 1 - valid_min_c
412
+ else:
413
+ min_c = int(np.min(cyc_int_raw))
414
+ shift = 1 - min_c if min_c <= 0 else 0
415
+ else:
416
+ shift = 0
417
+ cyc_int = cyc_int_raw + shift
418
+ cycles_present = sorted(int(c) for c in np.unique(cyc_int)) if cyc_int.size else [1]
419
+ # Color palette
420
+ base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
421
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
422
+ # Ensure font and canvas settings match GC/dQdV
423
+ _plt.rcParams.update({
424
+ 'font.family': 'sans-serif',
425
+ 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
426
+ 'mathtext.fontset': 'dejavusans',
427
+ 'font.size': 16
428
+ })
429
+ fig, ax = _plt.subplots(figsize=(10, 6))
430
+ cycle_lines = {}
431
+ for cyc in cycles_present:
432
+ mask = (cyc_int == cyc)
433
+ idx = np.where(mask)[0]
434
+ if idx.size >= 2:
435
+ # Insert NaNs between non-consecutive indices for proper cycle breaks
436
+ parts_x = []
437
+ parts_y = []
438
+ start = 0
439
+ for k in range(1, idx.size):
440
+ if idx[k] != idx[k-1] + 1:
441
+ parts_x.append(voltage[idx[start:k]])
442
+ parts_y.append(current[idx[start:k]])
443
+ start = k
444
+ parts_x.append(voltage[idx[start:]])
445
+ parts_y.append(current[idx[start:]])
446
+ X = []
447
+ Y = []
448
+ for i, (px, py) in enumerate(zip(parts_x, parts_y)):
449
+ if i > 0:
450
+ X.append(np.array([np.nan]))
451
+ Y.append(np.array([np.nan]))
452
+ X.append(px)
453
+ Y.append(py)
454
+ x_b = np.concatenate(X) if X else np.array([])
455
+ y_b = np.concatenate(Y) if Y else np.array([])
456
+ ln, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
457
+ linewidth=2.0, label=str(cyc), alpha=0.8)
458
+ cycle_lines[cyc] = ln
459
+ # Swap axis labels if --ro flag is set
460
+ if getattr(args, 'ro', False):
461
+ ax.set_xlabel('Current (mA)', labelpad=8.0)
462
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
463
+ else:
464
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
465
+ ax.set_ylabel('Current (mA)', labelpad=8.0)
466
+ legend = ax.legend(title='Cycle')
467
+ if legend is not None:
468
+ try:
469
+ legend.set_frame_on(False)
470
+ except Exception:
471
+ pass
472
+ legend.get_title().set_fontsize('medium')
473
+ # Match GC/dQdV: consistent label/title displacement and canvas
474
+ fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
475
+
476
+ # Apply style file if provided
477
+ if style_cfg:
478
+ try:
479
+ from .batch import _apply_ec_style
480
+ _apply_ec_style(fig, ax, style_cfg)
481
+ # Redraw after applying style
482
+ if hasattr(fig, 'canvas'):
483
+ fig.canvas.draw()
484
+ except Exception as e:
485
+ print(f"Warning: Error applying style file: {e}")
486
+
487
+ # Save if requested
488
+ if len(data_files) > 1 and (args.savefig or args.out):
489
+ # Multiple files: save to Figures/ directory
490
+ base_name = os.path.splitext(os.path.basename(ec_file))[0]
491
+ output_format = getattr(args, 'format', 'svg')
492
+ outname = os.path.join(out_dir, f"{base_name}.{output_format}")
493
+ try:
494
+ _, _ext = _os.path.splitext(outname)
495
+ if _ext.lower() == '.svg':
496
+ _plt.rcParams['svg.fonttype'] = 'none'
497
+ _plt.rcParams['svg.hashsalt'] = None
498
+ fig.savefig(outname, dpi=300, transparent=True if _ext.lower() == '.svg' else False)
499
+ print(f"CV plot saved to {outname}")
500
+ except Exception as e:
501
+ print(f"Warning: Could not save CV plot: {e}")
502
+
503
+ # Interactive menu: use electrochem_interactive_menu for consistency with GC
504
+ if args.interactive:
505
+ try:
506
+ _plt.ion()
507
+ except Exception:
508
+ pass
509
+ _plt.show(block=False)
510
+ try:
511
+ fig._bp_source_paths = [_os.path.abspath(ec_file)]
512
+ except Exception:
513
+ pass
514
+ try:
515
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
516
+ except Exception as _ie:
517
+ print(f"Interactive menu failed: {_ie}")
518
+ _plt.show()
519
+ else:
520
+ if not (args.savefig or args.out):
521
+ _plt.show()
522
+ # For multiple files, close the figure and continue to next file
523
+ if len(data_files) > 1:
524
+ _plt.close(fig)
525
+ continue
526
+ else:
527
+ exit(0)
528
+ except Exception as e:
529
+ print(f"CV plot failed for {ec_file}: {e}")
530
+ if len(data_files) > 1:
531
+ continue
532
+ else:
533
+ exit(1)
534
+ # Exit after processing all files
535
+ if len(data_files) > 1:
536
+ print(f"Processed {len(data_files)} CV files.")
537
+ exit(0)
538
+
539
+
540
+ """
541
+ batplot_v1.0.10: Interactively plot:
542
+ XRD data .xye, .xy, .qye, .dat, .csv
543
+ PDF data .gr
544
+ XAS data .nor, .chik, .chir
545
+ More features to be added.
546
+ """
547
+
548
+
549
+ # Ensure an interactive (GUI) backend when a window is expected (interactive mode or operando/GC plots)
550
+ def _ensure_gui_backend_for_interactive():
551
+ try:
552
+ argv = sys.argv
553
+ except Exception:
554
+ argv = []
555
+ # Trigger if interactive is requested OR when operando/GC plotting likely calls show()
556
+ wants_interactive = any(flag in argv for flag in ("--interactive",))
557
+ wants_interactive = wants_interactive or ("--operando" in argv)
558
+ wants_interactive = wants_interactive or ("--gc" in argv)
559
+ if not wants_interactive:
560
+ return
561
+ # If MPLBACKEND is set to a GUI backend, respect it; if it's non-interactive, we'll override below
562
+ env_be = os.environ.get("MPLBACKEND")
563
+ if env_be:
564
+ low = env_be.lower()
565
+ if low in {"macosx","tkagg","qtagg"}:
566
+ return
567
+ try:
568
+ be = _mpl.get_backend()
569
+ except Exception:
570
+ be = None
571
+ def _is_noninteractive(name):
572
+ if not isinstance(name, str):
573
+ return False
574
+ low = name.lower()
575
+ return ("agg" in low) or ("inline" in low) or (low in {"pdf","ps","svg","template"})
576
+ if not _is_noninteractive(be):
577
+ return
578
+ # Try GUI backends in order of likelihood
579
+ candidates = [
580
+ ("darwin", ["MacOSX", "TkAgg", "QtAgg"]),
581
+ ("win", ["TkAgg", "QtAgg"]),
582
+ ("other", ["TkAgg", "QtAgg"]),
583
+ ]
584
+ plat = sys.platform
585
+ if plat == "darwin":
586
+ order = candidates[0][1]
587
+ elif plat.startswith("win"):
588
+ order = candidates[1][1]
589
+ else:
590
+ order = candidates[2][1]
591
+ import importlib.util as _ilus
592
+ for cand in order:
593
+ try:
594
+ if cand == "TkAgg":
595
+ if _ilus.find_spec("tkinter") is None:
596
+ continue
597
+ elif cand == "QtAgg":
598
+ if (_ilus.find_spec("PyQt5") is None) and (_ilus.find_spec("PySide6") is None):
599
+ continue
600
+ # MacOSX: attempt; will fail on non-framework builds
601
+ _mpl.use(cand, force=True)
602
+ break
603
+ except Exception:
604
+ continue
605
+
606
+ _ensure_gui_backend_for_interactive()
607
+
608
+ import matplotlib.pyplot as plt
609
+ # Note: All imports moved to module level for clean import behavior
610
+
611
+ # Set global default font
612
+ plt.rcParams.update({
613
+ 'font.family': 'sans-serif',
614
+ 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
615
+ 'mathtext.fontset': 'dejavusans', # keeps math consistent with Arial-like sans
616
+ 'font.size': 16
617
+ })
618
+
619
+ # Parse CLI arguments early; many top-level branches depend on args
620
+ args = _bp_parse_args()
621
+
622
+
623
+ """
624
+ Note: CIF parsing and simulation helpers now come from batplot.cif.
625
+ This file defers to simulate_cif_pattern_Q and cif_reflection_positions
626
+ imported above to avoid duplicating heavy logic here.
627
+ """
628
+
629
+ # ---------------- Conversion Function ----------------
630
+ # Implemented in batplot.converters as convert_to_qye
631
+
632
+ # Readers now live in batplot.readers; avoid duplicating implementations here.
633
+
634
+ # ---------------- .gr (Pair Distribution Function) Reading ----------------
635
+
636
+ # Label layout handled by plotting.update_labels imported at top.
637
+
638
+ #!/ End of legacy inline interactive_menu.
639
+ # Normal XY interactive menu is imported from batplot.interactive as `interactive_menu`.
640
+
641
+ # Galvanostatic cycling mode check: .mpt or supported .csv file with --gc flag
642
+ if getattr(args, 'gc', False):
643
+ import os as _os
644
+ import matplotlib.pyplot as _plt
645
+
646
+ # Separate style files from data files
647
+ data_files = []
648
+ style_file_path = None
649
+ for f in args.files:
650
+ ext = os.path.splitext(f)[1].lower()
651
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
652
+ if style_file_path is None:
653
+ style_file_path = f
654
+ else:
655
+ print(f"Warning: Multiple style files provided, using first: {style_file_path}")
656
+ else:
657
+ data_files.append(f)
658
+
659
+ if not data_files:
660
+ print("GC mode: no data files found (only style files provided).")
661
+ exit(1)
662
+
663
+ # Load style file if provided
664
+ style_cfg = None
665
+ if style_file_path:
666
+ if not os.path.isfile(style_file_path):
667
+ print(f"Warning: Style file not found: {style_file_path}")
668
+ else:
669
+ try:
670
+ with open(style_file_path, 'r', encoding='utf-8') as f:
671
+ style_cfg = json.load(f)
672
+ print(f"Using style file: {os.path.basename(style_file_path)}")
673
+ except Exception as e:
674
+ print(f"Warning: Could not load style file {style_file_path}: {e}")
675
+
676
+ # Process each data file
677
+ from .utils import ensure_subdirectory
678
+ out_dir = None
679
+ if len(data_files) > 1 and (args.savefig or args.out):
680
+ # Multiple files: create output directory
681
+ out_dir = ensure_subdirectory('Figures', os.getcwd())
682
+
683
+ for ec_file_idx, ec_file in enumerate(data_files):
684
+ if not _os.path.isfile(ec_file):
685
+ print(f"File not found: {ec_file}")
686
+ continue
687
+
688
+ try:
689
+ # Branch by extension
690
+ if ec_file.lower().endswith('.mpt'):
691
+ # For .mpt, mass is required to compute specific capacity
692
+ mass_mg = getattr(args, 'mass', None)
693
+ if mass_mg is None:
694
+ print("GC mode (.mpt): --mass parameter is required (active material mass in milligrams).")
695
+ print("Example: batplot file.mpt --gc --mass 7.0")
696
+ if len(data_files) > 1:
697
+ continue
698
+ else:
699
+ exit(1)
700
+ specific_capacity, voltage, cycle_numbers, charge_mask, discharge_mask = read_mpt_file(ec_file, mode='gc', mass_mg=mass_mg)
701
+ x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
702
+ cap_x = specific_capacity
703
+ elif ec_file.lower().endswith('.csv'):
704
+ # Check if this is CS-B format
705
+ try:
706
+ header, _, _ = _load_csv_header_and_rows(ec_file)
707
+ if is_cs_b_format(header):
708
+ # Use CS-B format reader
709
+ cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_cs_b_csv_file(ec_file, mode='gc')
710
+ else:
711
+ # Use standard CSV reader
712
+ cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_ec_csv_file(ec_file, prefer_specific=True)
713
+ except Exception:
714
+ # Fallback to standard reader
715
+ cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_ec_csv_file(ec_file, prefer_specific=True)
716
+ x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
717
+ else:
718
+ print(f"GC mode: file must be .mpt or .csv: {ec_file}")
719
+ if len(data_files) > 1:
720
+ continue
721
+ else:
722
+ exit(1)
723
+
724
+ # Create the plot
725
+ fig, ax = _plt.subplots(figsize=(10, 6))
726
+
727
+ # Build per-cycle lines for charge and discharge
728
+ def _contiguous_blocks(mask):
729
+ inds = np.where(mask)[0]
730
+ if inds.size == 0:
731
+ return []
732
+ blocks = []
733
+ start = inds[0]
734
+ prev = inds[0]
735
+ for j in inds[1:]:
736
+ if j == prev + 1:
737
+ prev = j
738
+ else:
739
+ blocks.append((start, prev))
740
+ start = j
741
+ prev = j
742
+ blocks.append((start, prev))
743
+ return blocks
744
+
745
+ def _broken_arrays_from_indices(idx: np.ndarray, x: np.ndarray, y: np.ndarray):
746
+ """Insert NaNs between non-consecutive indices so a single Line2D can represent disjoint segments."""
747
+ if idx.size == 0:
748
+ return np.array([]), np.array([])
749
+ parts_x = []
750
+ parts_y = []
751
+ start = 0
752
+ for k in range(1, idx.size):
753
+ if idx[k] != idx[k-1] + 1:
754
+ parts_x.append(x[idx[start:k]])
755
+ parts_y.append(y[idx[start:k]])
756
+ start = k
757
+ parts_x.append(x[idx[start:]])
758
+ parts_y.append(y[idx[start:]])
759
+ # Concatenate with NaN separators
760
+ X = []
761
+ Y = []
762
+ for i, (px, py) in enumerate(zip(parts_x, parts_y)):
763
+ if i > 0:
764
+ X.append(np.array([np.nan]))
765
+ Y.append(np.array([np.nan]))
766
+ X.append(px)
767
+ Y.append(py)
768
+ return np.concatenate(X) if X else np.array([]), np.concatenate(Y) if Y else np.array([])
769
+
770
+ if cycle_numbers is not None:
771
+ # Normalize cycle indices to start at 1 (BioLogic may start at 0)
772
+ # But first, identify cycles with sufficient data (>= 2 points) to be plotted
773
+ cyc_int_raw = np.array(np.rint(cycle_numbers), dtype=int)
774
+ if cyc_int_raw.size:
775
+ # Find the minimum cycle number that has at least 2 data points
776
+ unique_cycles_raw = np.unique(cyc_int_raw)
777
+ valid_min_c = None
778
+ for c in sorted(unique_cycles_raw):
779
+ if np.sum(cyc_int_raw == c) >= 2:
780
+ valid_min_c = int(c)
781
+ break
782
+
783
+ if valid_min_c is not None:
784
+ # Shift so the first valid cycle becomes cycle 1
785
+ shift = 1 - valid_min_c
786
+ else:
787
+ # No valid cycles found, use original min
788
+ min_c = int(np.min(cyc_int_raw))
789
+ shift = 1 - min_c if min_c <= 0 else 0
790
+ else:
791
+ shift = 0
792
+
793
+ cyc_int = cyc_int_raw + shift
794
+ cycles_present = sorted(int(c) for c in np.unique(cyc_int))
795
+ else:
796
+ cycles_present = [1]
797
+
798
+ # Determine if cycle numbers are meaningful
799
+ inferred = len(cycles_present) <= 1
800
+ if inferred:
801
+ ch_blocks = _contiguous_blocks(charge_mask)
802
+ dch_blocks = _contiguous_blocks(discharge_mask)
803
+ cycles_present = list(range(1, max(len(ch_blocks), len(dch_blocks)) + 1)) if (ch_blocks or dch_blocks) else [1]
804
+
805
+ # Prepare colors
806
+ base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
807
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
808
+
809
+ # Mapping: cycle_number -> {'charge': Line2D|None, 'discharge': Line2D|None}
810
+ cycle_lines = {}
811
+
812
+ if not inferred and cycle_numbers is not None:
813
+ for cyc in cycles_present:
814
+ # Charge
815
+ mask_c = (cyc_int == cyc) & charge_mask
816
+ idx = np.where(mask_c)[0]
817
+ if idx.size >= 2:
818
+ x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
819
+ # Label only once per cycle for legend: Cycle N
820
+ # Swap x and y if --ro flag is set
821
+ if getattr(args, 'ro', False):
822
+ ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
823
+ linewidth=2.0, label=str(cyc), alpha=0.8)
824
+ else:
825
+
826
+ ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
827
+ linewidth=2.0, label=str(cyc), alpha=0.8)
828
+ else:
829
+ ln_c = None
830
+ # Discharge
831
+ mask_d = (cyc_int == cyc) & discharge_mask
832
+ idxd = np.where(mask_d)[0]
833
+ if idxd.size >= 2:
834
+ xd_b, yd_b = _broken_arrays_from_indices(idxd, cap_x, voltage)
835
+ # Use no legend entry for the second line of the same cycle
836
+ lbl = '_nolegend_' if ln_c is not None else str(cyc)
837
+ # Swap x and y if --ro flag is set
838
+ if getattr(args, 'ro', False):
839
+ ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
840
+ linewidth=2.0, label=lbl, alpha=0.8)
841
+ else:
842
+
843
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
844
+ linewidth=2.0, label=lbl, alpha=0.8)
845
+ else:
846
+ ln_d = None
847
+ cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
848
+ else:
849
+ # Infer cycles by alternating contiguous charge/discharge blocks
850
+ ch_blocks = _contiguous_blocks(charge_mask)
851
+ dch_blocks = _contiguous_blocks(discharge_mask)
852
+ N = max(len(ch_blocks), len(dch_blocks))
853
+ for i in range(N):
854
+ cyc = i + 1
855
+ ln_c = None
856
+ if i < len(ch_blocks):
857
+ a, b = ch_blocks[i]
858
+ idx = np.arange(a, b + 1)
859
+ x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
860
+ # Swap x and y if --ro flag is set
861
+ if getattr(args, 'ro', False):
862
+ ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
863
+ linewidth=2.0, label=str(cyc), alpha=0.8)
864
+ else:
865
+
866
+ ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
867
+ linewidth=2.0, label=str(cyc), alpha=0.8)
868
+ ln_d = None
869
+ if i < len(dch_blocks):
870
+ a, b = dch_blocks[i]
871
+ idx = np.arange(a, b + 1)
872
+ xd_b, yd_b = _broken_arrays_from_indices(idx, cap_x, voltage)
873
+ lbl = '_nolegend_' if ln_c is not None else str(cyc)
874
+ # Swap x and y if --ro flag is set
875
+ if getattr(args, 'ro', False):
876
+ ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
877
+ linewidth=2.0, label=lbl, alpha=0.8)
878
+ else:
879
+
880
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
881
+ linewidth=2.0, label=lbl, alpha=0.8)
882
+ cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
883
+ # Labels with consistent labelpad
884
+ # Swap axis labels if --ro flag is set
885
+ if getattr(args, 'ro', False):
886
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
887
+ ax.set_ylabel(x_label_gc, labelpad=8.0)
888
+ else:
889
+ ax.set_xlabel(x_label_gc, labelpad=8.0)
890
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
891
+ legend = ax.legend(title='Cycle')
892
+ if legend is not None:
893
+ try:
894
+ legend.set_frame_on(False)
895
+ except Exception:
896
+ pass
897
+ legend.get_title().set_fontsize('medium')
898
+ # No background grid by default for GC plots
899
+
900
+ # Adjust layout to ensure top and bottom labels/titles are visible
901
+ fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
902
+
903
+ # Apply style file if provided
904
+ if style_cfg:
905
+ try:
906
+ from .batch import _apply_ec_style
907
+ _apply_ec_style(fig, ax, style_cfg)
908
+ # Redraw after applying style
909
+ fig.canvas.draw() if hasattr(fig, 'canvas') else None
910
+ except Exception as e:
911
+ print(f"Warning: Error applying style file: {e}")
912
+
913
+ # Save if requested
914
+ if len(data_files) > 1 and (args.savefig or args.out):
915
+ # Multiple files: save to Figures/ directory
916
+ base_name = os.path.splitext(os.path.basename(ec_file))[0]
917
+ output_format = getattr(args, 'format', 'svg')
918
+ outname = os.path.join(out_dir, f"{base_name}.{output_format}")
919
+ else:
920
+ outname = args.savefig or args.out
921
+ if outname:
922
+ if not _os.path.splitext(outname)[1]:
923
+ outname += '.svg'
924
+ # Transparent background for SVG exports
925
+ _, _ext = _os.path.splitext(outname)
926
+ if _ext.lower() == '.svg':
927
+ # Fix for Affinity Designer/Photo compatibility issues
928
+ # Use 'none' to embed fonts as text (not paths) - prevents phantom labels
929
+ # Set hashsalt to empty to avoid duplicate text elements
930
+ _plt.rcParams['svg.fonttype'] = 'none'
931
+ _plt.rcParams['svg.hashsalt'] = None
932
+ try:
933
+ _fig_fc = fig.get_facecolor()
934
+ except Exception:
935
+ _fig_fc = None
936
+ try:
937
+ _ax_fc = ax.get_facecolor()
938
+ except Exception:
939
+ _ax_fc = None
940
+ try:
941
+ if getattr(fig, 'patch', None) is not None:
942
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
943
+ if getattr(ax, 'patch', None) is not None:
944
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
945
+ except Exception:
946
+ pass
947
+ try:
948
+ fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
949
+ finally:
950
+ try:
951
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
952
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
953
+ except Exception:
954
+ pass
955
+ try:
956
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
957
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
958
+ except Exception:
959
+ pass
960
+ else:
961
+ fig.savefig(outname, dpi=300)
962
+ print(f"GC plot saved to {outname} ({x_label_gc})")
963
+
964
+ # Show plot / interactive menu
965
+ if args.interactive:
966
+ # Guard against non-interactive backends (e.g., Agg)
967
+ try:
968
+ _backend = _plt.get_backend()
969
+ except Exception:
970
+ _backend = "unknown"
971
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
972
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
973
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
974
+ if _is_noninteractive:
975
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
976
+ print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
977
+ print(" export MPLBACKEND=MacOSX # built-in macOS backend")
978
+ print(" export MPLBACKEND=TkAgg # if Tk is available")
979
+ print(" export MPLBACKEND=QtAgg # if PyQt is installed")
980
+ print("Or run without --interactive and use --out to save the figure.")
981
+ else:
982
+ # Turn on interactive mode and show non-blocking window
983
+ try:
984
+ _plt.ion()
985
+ except Exception:
986
+ pass
987
+ _plt.show(block=False)
988
+ try:
989
+ fig._bp_source_paths = [_os.path.abspath(ec_file)]
990
+ except Exception:
991
+ pass
992
+ try:
993
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
994
+ except Exception as _ie:
995
+ print(f"Interactive menu failed: {_ie}")
996
+ # Keep window open after menu
997
+ _plt.show()
998
+ else:
999
+ if not (args.savefig or args.out):
1000
+ # Only show when a GUI backend is available
1001
+ try:
1002
+ _backend = _plt.get_backend()
1003
+ except Exception:
1004
+ _backend = "unknown"
1005
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1006
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1007
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1008
+ if not _is_noninteractive:
1009
+ _plt.show()
1010
+ else:
1011
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
1012
+ # For multiple files, close the figure and continue to next file
1013
+ if len(data_files) > 1:
1014
+ _plt.close(fig)
1015
+ continue
1016
+ else:
1017
+ exit()
1018
+ except Exception as _e:
1019
+ print(f"GC plot failed for {ec_file}: {_e}")
1020
+ if len(data_files) > 1:
1021
+ continue
1022
+ else:
1023
+ exit(1)
1024
+ # Exit after processing all files
1025
+ if len(data_files) > 1:
1026
+ print(f"Processed {len(data_files)} GC files.")
1027
+ exit()
1028
+
1029
+ # Capacity-per-cycle (CPC) summary from CSV or .mpt with coulombic efficiency
1030
+ if getattr(args, 'cpc', False):
1031
+ import os as _os
1032
+ import numpy as _np
1033
+
1034
+ # Separate style files from data files
1035
+ data_files = []
1036
+ style_file_path = None
1037
+ for f in args.files:
1038
+ ext = os.path.splitext(f)[1].lower()
1039
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
1040
+ if style_file_path is None:
1041
+ style_file_path = f
1042
+ else:
1043
+ print(f"Warning: Multiple style files provided, using first: {style_file_path}")
1044
+ else:
1045
+ data_files.append(f)
1046
+
1047
+ if len(data_files) < 1:
1048
+ print("CPC mode: provide at least one file (.csv, .xlsx, or .mpt).")
1049
+ exit(1)
1050
+
1051
+ # Load style file if provided
1052
+ style_cfg = None
1053
+ if style_file_path:
1054
+ if not os.path.isfile(style_file_path):
1055
+ print(f"Warning: Style file not found: {style_file_path}")
1056
+ else:
1057
+ try:
1058
+ with open(style_file_path, 'r', encoding='utf-8') as f:
1059
+ style_cfg = json.load(f)
1060
+ print(f"Using style file: {os.path.basename(style_file_path)}")
1061
+ except Exception as e:
1062
+ print(f"Warning: Could not load style file {style_file_path}: {e}")
1063
+
1064
+ # Process multiple files
1065
+ file_data = [] # List of dicts with file info and data
1066
+ # Use tab10 for capacity and viridis for efficiency
1067
+ import matplotlib.cm as cm
1068
+ import matplotlib.colors as mcolors
1069
+ n_files = len(data_files)
1070
+
1071
+ # Use tab10 hardcoded colors for capacity (matching interactive menu)
1072
+ default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
1073
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
1074
+ if n_files <= 1:
1075
+ capacity_colors = [default_tab10_colors[0]]
1076
+ eff_positions = [0.55]
1077
+ else:
1078
+ capacity_colors = [default_tab10_colors[i % len(default_tab10_colors)] for i in range(n_files)]
1079
+ eff_positions = np.linspace(0.08, 0.88, n_files)
1080
+
1081
+ # Use viridis for efficiency
1082
+ efficiency_cmap = cm.get_cmap('viridis')
1083
+ efficiency_colors = [mcolors.rgb2hex(efficiency_cmap(pos)[:3]) for pos in eff_positions]
1084
+
1085
+ for file_idx, ec_file in enumerate(data_files):
1086
+ if not _os.path.isfile(ec_file):
1087
+ print(f"File not found: {ec_file}")
1088
+ continue
1089
+
1090
+ ext = _os.path.splitext(ec_file)[1].lower()
1091
+ file_basename = _os.path.basename(ec_file)
1092
+
1093
+ try:
1094
+ if ext in ['.csv', '.xlsx', '.xls']:
1095
+ # Check if this is CS-B format
1096
+ try:
1097
+ header, _, _ = _load_csv_header_and_rows(ec_file)
1098
+ if is_cs_b_format(header):
1099
+ # Use CS-B format reader for CPC
1100
+ cyc_nums, cap_charge, cap_discharge, eff = read_cs_b_csv_file(ec_file, mode='cpc')
1101
+ else:
1102
+ # Use standard CSV reader
1103
+ cap_x, voltage, cycles, chg_mask, dchg_mask = read_ec_csv_file(ec_file, prefer_specific=True)
1104
+ cyc = _np.array(cycles, dtype=int)
1105
+ unique_cycles = _np.unique(cyc)
1106
+ unique_cycles = unique_cycles[_np.isfinite(unique_cycles)]
1107
+ unique_cycles = [int(x) for x in unique_cycles]
1108
+ if not unique_cycles:
1109
+ unique_cycles = [1]
1110
+ cyc_nums = []
1111
+ cap_charge = []
1112
+ cap_discharge = []
1113
+ eff = []
1114
+ for c in sorted(unique_cycles):
1115
+ m_c = (cyc == c)
1116
+ qchg = _np.nanmax(cap_x[m_c & chg_mask]) if _np.any(m_c & chg_mask) else _np.nan
1117
+ qdch = _np.nanmax(cap_x[m_c & dchg_mask]) if _np.any(m_c & dchg_mask) else _np.nan
1118
+ eta = (qdch / qchg * 100.0) if (_np.isfinite(qchg) and qchg > 0 and _np.isfinite(qdch)) else _np.nan
1119
+ cyc_nums.append(c)
1120
+ cap_charge.append(qchg)
1121
+ cap_discharge.append(qdch)
1122
+ eff.append(eta)
1123
+ cyc_nums = _np.array(cyc_nums, dtype=float)
1124
+ cap_charge = _np.array(cap_charge, dtype=float)
1125
+ cap_discharge = _np.array(cap_discharge, dtype=float)
1126
+ eff = _np.array(eff, dtype=float)
1127
+ except Exception as e:
1128
+ # Fallback to standard reader
1129
+ cap_x, voltage, cycles, chg_mask, dchg_mask = read_ec_csv_file(ec_file, prefer_specific=True)
1130
+ cyc = _np.array(cycles, dtype=int)
1131
+ unique_cycles = _np.unique(cyc)
1132
+ unique_cycles = unique_cycles[_np.isfinite(unique_cycles)]
1133
+ unique_cycles = [int(x) for x in unique_cycles]
1134
+ if not unique_cycles:
1135
+ unique_cycles = [1]
1136
+ cyc_nums = []
1137
+ cap_charge = []
1138
+ cap_discharge = []
1139
+ eff = []
1140
+ for c in sorted(unique_cycles):
1141
+ m_c = (cyc == c)
1142
+ qchg = _np.nanmax(cap_x[m_c & chg_mask]) if _np.any(m_c & chg_mask) else _np.nan
1143
+ qdch = _np.nanmax(cap_x[m_c & dchg_mask]) if _np.any(m_c & dchg_mask) else _np.nan
1144
+ eta = (qdch / qchg * 100.0) if (_np.isfinite(qchg) and qchg > 0 and _np.isfinite(qdch)) else _np.nan
1145
+ cyc_nums.append(c)
1146
+ cap_charge.append(qchg)
1147
+ cap_discharge.append(qdch)
1148
+ eff.append(eta)
1149
+ cyc_nums = _np.array(cyc_nums, dtype=float)
1150
+ cap_charge = _np.array(cap_charge, dtype=float)
1151
+ cap_discharge = _np.array(cap_discharge, dtype=float)
1152
+ eff = _np.array(eff, dtype=float)
1153
+ elif ext == '.mpt':
1154
+ mass_mg = getattr(args, 'mass', None)
1155
+ if mass_mg is None:
1156
+ print(f"Skipped {file_basename}: CPC mode (.mpt) requires --mass parameter.")
1157
+ continue
1158
+ cyc_nums, cap_charge, cap_discharge, eff = read_mpt_file(ec_file, mode='cpc', mass_mg=mass_mg)
1159
+ else:
1160
+ print(f"Skipped {file_basename}: unsupported format (must be .csv, .xlsx, or .mpt)")
1161
+ continue
1162
+
1163
+ # Assign colors: distinct hue for each file
1164
+ capacity_color = capacity_colors[file_idx % len(capacity_colors)]
1165
+ efficiency_color = efficiency_colors[file_idx % len(efficiency_colors)]
1166
+ # Generate discharge color using the same function as interactive menu
1167
+ discharge_color = _generate_similar_color(capacity_color)
1168
+
1169
+ file_data.append({
1170
+ 'filename': file_basename,
1171
+ 'filepath': ec_file,
1172
+ 'cyc_nums': cyc_nums,
1173
+ 'cap_charge': cap_charge,
1174
+ 'cap_discharge': cap_discharge,
1175
+ 'eff': eff,
1176
+ 'color': capacity_color,
1177
+ 'discharge_color': discharge_color,
1178
+ 'eff_color': efficiency_color,
1179
+ 'visible': True
1180
+ })
1181
+
1182
+ except Exception as e:
1183
+ print(f"Failed to read {file_basename}: {e}")
1184
+ continue
1185
+
1186
+ if not file_data:
1187
+ print("No valid CPC data files to plot.")
1188
+ exit(1)
1189
+
1190
+ # Plot (same figsize as GC)
1191
+ fig, ax = plt.subplots(figsize=(10, 6))
1192
+ ax.set_xlabel('Cycle number', labelpad=8.0)
1193
+ ax.set_ylabel(r'Specific Capacity (mAh g$^{-1}$)', labelpad=8.0)
1194
+ ax.grid(True, alpha=0.25, linestyle='--', linewidth=0.8)
1195
+
1196
+ ax2 = ax.twinx()
1197
+ ax2.set_ylabel('Efficiency (%)', labelpad=8.0)
1198
+
1199
+ # Create scatter plots for each file
1200
+ for file_info in file_data:
1201
+ cyc_nums = file_info['cyc_nums']
1202
+ cap_charge = file_info['cap_charge']
1203
+ cap_discharge = file_info['cap_discharge']
1204
+ eff = file_info['eff']
1205
+ color = file_info['color'] # Warm color for capacity
1206
+ eff_color = file_info['eff_color'] # Cold color for efficiency
1207
+ label = file_info['filename']
1208
+
1209
+ # For single file, use simple labels; for multiple files, prefix with filename
1210
+ if len(file_data) == 1:
1211
+ label_chg = 'Charge capacity'
1212
+ label_dch = 'Discharge capacity'
1213
+ label_eff = 'Coulombic efficiency'
1214
+ else:
1215
+ label_chg = f'{label} (Chg)'
1216
+ label_dch = f'{label} (Dch)'
1217
+ label_eff = f'{label} (Eff)'
1218
+
1219
+ # Use stored discharge color if available, otherwise generate it
1220
+ if 'discharge_color' in file_info and file_info['discharge_color'] is not None:
1221
+ discharge_color = file_info['discharge_color']
1222
+ else:
1223
+ discharge_color = _generate_similar_color(color)
1224
+
1225
+ sc_charge = ax.scatter(cyc_nums, cap_charge, color=color, label=label_chg,
1226
+ s=32, zorder=3, alpha=0.8, marker='o')
1227
+ sc_discharge = ax.scatter(cyc_nums, cap_discharge, color=discharge_color, label=label_dch,
1228
+ s=32, zorder=3, alpha=0.8, marker='s')
1229
+ sc_eff = ax2.scatter(cyc_nums, eff, color=eff_color, marker='^', label=label_eff,
1230
+ s=40, alpha=0.7, zorder=3)
1231
+
1232
+ # Store scatter artists in file_info for interactive menu
1233
+ file_info['sc_charge'] = sc_charge
1234
+ file_info['sc_discharge'] = sc_discharge
1235
+ file_info['sc_eff'] = sc_eff
1236
+
1237
+ # Set efficiency y-range to 0-120 by default
1238
+ ax2.set_ylim(0, 120)
1239
+
1240
+ # Compose a combined legend
1241
+ try:
1242
+ h1, l1 = ax.get_legend_handles_labels()
1243
+ h2, l2 = ax2.get_legend_handles_labels()
1244
+ combined_handles = h1 + h2
1245
+ leg = ax.legend(
1246
+ combined_handles, l1 + l2,
1247
+ loc='best',
1248
+ frameon=False,
1249
+ handlelength=1.0,
1250
+ handletextpad=0.35,
1251
+ labelspacing=0.25,
1252
+ borderaxespad=0.5,
1253
+ borderpad=0.3,
1254
+ columnspacing=0.6,
1255
+ labelcolor='linecolor',
1256
+ )
1257
+ if leg is not None:
1258
+ try:
1259
+ leg.set_frame_on(False)
1260
+ leg.set_edgecolor('none')
1261
+ leg.set_facecolor('none')
1262
+ except Exception:
1263
+ pass
1264
+ except Exception:
1265
+ pass
1266
+
1267
+ # Adjust layout to ensure top and bottom labels/titles are visible
1268
+ fig.subplots_adjust(left=0.12, right=0.88, top=0.88, bottom=0.15)
1269
+
1270
+ # Check for style file in file list
1271
+ style_file_path = None
1272
+ for f in args.files:
1273
+ ext = os.path.splitext(f)[1].lower()
1274
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
1275
+ style_file_path = f
1276
+ break
1277
+
1278
+ # Load and apply style file if provided
1279
+ if style_file_path:
1280
+ if os.path.isfile(style_file_path):
1281
+ try:
1282
+ with open(style_file_path, 'r', encoding='utf-8') as f:
1283
+ style_cfg = json.load(f)
1284
+ print(f"Using style file: {os.path.basename(style_file_path)}")
1285
+ from .batch import _apply_ec_style
1286
+ _apply_ec_style(fig, ax, style_cfg)
1287
+ # Also apply to twin axis
1288
+ _apply_ec_style(fig, ax2, style_cfg)
1289
+ # Redraw after applying style
1290
+ if hasattr(fig, 'canvas'):
1291
+ fig.canvas.draw()
1292
+ except Exception as e:
1293
+ print(f"Warning: Error applying style file: {e}")
1294
+ else:
1295
+ print(f"Warning: Style file not found: {style_file_path}")
1296
+
1297
+ if args.interactive and cpc_interactive_menu is not None:
1298
+ # Guard against non-interactive backends (e.g., Agg)
1299
+ try:
1300
+ _backend = plt.get_backend()
1301
+ except Exception:
1302
+ _backend = "unknown"
1303
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1304
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1305
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1306
+ if _is_noninteractive:
1307
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
1308
+ print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
1309
+ print(" export MPLBACKEND=MacOSX # built-in macOS backend")
1310
+ print(" export MPLBACKEND=TkAgg # if Tk is available")
1311
+ print(" export MPLBACKEND=QtAgg # if PyQt is installed")
1312
+ print("Or run without --interactive and use --out to save the figure.")
1313
+ else:
1314
+ try:
1315
+ plt.ion()
1316
+ except Exception:
1317
+ pass
1318
+ plt.show(block=False)
1319
+ try:
1320
+ # Always pass file_data so filename is available
1321
+ cpc_interactive_menu(fig, ax, ax2,
1322
+ file_data[0]['sc_charge'],
1323
+ file_data[0]['sc_discharge'],
1324
+ file_data[0]['sc_eff'],
1325
+ file_data=file_data)
1326
+ except Exception as _ie:
1327
+ print(f"CPC interactive menu failed: {_ie}")
1328
+ # Keep window open after menu
1329
+ plt.show()
1330
+ else:
1331
+ if not (args.savefig or args.out):
1332
+ try:
1333
+ _backend = plt.get_backend()
1334
+ except Exception:
1335
+ _backend = "unknown"
1336
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1337
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1338
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1339
+ if not _is_noninteractive:
1340
+ plt.show()
1341
+ else:
1342
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
1343
+ exit(0)
1344
+
1345
+ # dQ/dV plotting mode for supported .csv electrochemistry exports
1346
+ if getattr(args, 'dqdv', False):
1347
+ import os as _os
1348
+ import matplotlib.pyplot as _plt
1349
+
1350
+ # Separate style files from data files
1351
+ data_files = []
1352
+ style_file_path = None
1353
+ for f in args.files:
1354
+ ext = os.path.splitext(f)[1].lower()
1355
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
1356
+ if style_file_path is None:
1357
+ style_file_path = f
1358
+ else:
1359
+ print(f"Warning: Multiple style files provided, using first: {style_file_path}")
1360
+ else:
1361
+ data_files.append(f)
1362
+
1363
+ if not data_files:
1364
+ print("dQ/dV mode: no data files found (only style files provided).")
1365
+ exit(1)
1366
+
1367
+ # Load style file if provided
1368
+ style_cfg = None
1369
+ if style_file_path:
1370
+ if not os.path.isfile(style_file_path):
1371
+ print(f"Warning: Style file not found: {style_file_path}")
1372
+ else:
1373
+ try:
1374
+ with open(style_file_path, 'r', encoding='utf-8') as f:
1375
+ style_cfg = json.load(f)
1376
+ print(f"Using style file: {os.path.basename(style_file_path)}")
1377
+ except Exception as e:
1378
+ print(f"Warning: Could not load style file {style_file_path}: {e}")
1379
+
1380
+ # Process each data file
1381
+ from .utils import ensure_subdirectory
1382
+ out_dir = None
1383
+ if len(data_files) > 1 and (args.savefig or args.out):
1384
+ # Multiple files: create output directory
1385
+ out_dir = ensure_subdirectory('Figures', os.getcwd())
1386
+
1387
+ for ec_file in data_files:
1388
+ if not _os.path.isfile(ec_file):
1389
+ print(f"File not found: {ec_file}")
1390
+ continue
1391
+ if not (ec_file.lower().endswith('.csv') or ec_file.lower().endswith('.mpt')):
1392
+ print(f"dQ/dV mode: file must be a supported cycler .csv or .mpt export: {ec_file}")
1393
+ continue
1394
+
1395
+ try:
1396
+ # Load voltage, dQ/dV, cycles, and charge/discharge masks
1397
+ if ec_file.lower().endswith('.mpt'):
1398
+ # .mpt files require mass for dQ/dV calculation
1399
+ mass_mg = getattr(args, 'mass', None)
1400
+ if mass_mg is None or mass_mg <= 0:
1401
+ print(f"dQ/dV mode (.mpt): --mass parameter is required (active material mass in milligrams).")
1402
+ print(f"Example: batplot {ec_file} --dqdv --mass 7.0")
1403
+ continue
1404
+ voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_mpt_dqdv_file(ec_file, mass_mg=mass_mg, prefer_specific=True)
1405
+ else:
1406
+ # Check if this is CS-B format
1407
+ try:
1408
+ header, _, _ = _load_csv_header_and_rows(ec_file)
1409
+ if is_cs_b_format(header):
1410
+ # Use CS-B format reader
1411
+ voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_cs_b_csv_file(ec_file, mode='dqdv')
1412
+ else:
1413
+ # Use standard CSV reader
1414
+ voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_ec_csv_dqdv_file(ec_file, prefer_specific=True)
1415
+ except Exception:
1416
+ # Fallback to standard reader
1417
+ voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_ec_csv_dqdv_file(ec_file, prefer_specific=True)
1418
+
1419
+ # Create the plot
1420
+ fig, ax = _plt.subplots(figsize=(10, 6))
1421
+
1422
+ def _mask_segments(mask: np.ndarray, role: str):
1423
+ inds = np.where(mask)[0]
1424
+ if inds.size == 0:
1425
+ return []
1426
+ segments = []
1427
+ start = inds[0]
1428
+ prev = inds[0]
1429
+ for idx in inds[1:]:
1430
+ if idx == prev + 1:
1431
+ prev = idx
1432
+ else:
1433
+ segments.append((start, prev, role))
1434
+ start = idx
1435
+ prev = idx
1436
+ segments.append((start, prev, role))
1437
+ return segments
1438
+
1439
+ segments = _mask_segments(charge_mask, 'charge') + _mask_segments(discharge_mask, 'discharge')
1440
+ segments.sort(key=lambda item: item[0])
1441
+
1442
+ base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
1443
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
1444
+
1445
+ cycle_lines = {}
1446
+ ax._is_dqdv_mode = True
1447
+ cycle_id = 1
1448
+ cycle_lines[cycle_id] = {"charge": None, "discharge": None}
1449
+
1450
+ def _append_segment(line_obj, x_new, y_new):
1451
+ try:
1452
+ x_old = np.asarray(line_obj.get_xdata(), float)
1453
+ y_old = np.asarray(line_obj.get_ydata(), float)
1454
+ x_cat = np.concatenate([x_old, np.array([np.nan]), x_new])
1455
+ y_cat = np.concatenate([y_old, np.array([np.nan]), y_new])
1456
+ line_obj.set_xdata(x_cat)
1457
+ line_obj.set_ydata(y_cat)
1458
+ except Exception:
1459
+ pass
1460
+
1461
+ for start, end, role in segments:
1462
+ if end - start + 1 < 2:
1463
+ continue
1464
+ idx = np.arange(start, end + 1)
1465
+ x_seg = voltage[idx]
1466
+ y_seg = dqdv[idx]
1467
+ current = cycle_lines.setdefault(cycle_id, {"charge": None, "discharge": None})
1468
+ color = base_colors[(cycle_id - 1) % len(base_colors)]
1469
+ first_segment = current['charge'] is None and current['discharge'] is None
1470
+
1471
+ if current[role] is not None:
1472
+ if current['charge'] is not None and current['discharge'] is not None:
1473
+ cycle_id += 1
1474
+ current = cycle_lines.setdefault(cycle_id, {"charge": None, "discharge": None})
1475
+ else:
1476
+ # Swap x and y if --ro flag is set when appending segment
1477
+ if getattr(args, 'ro', False):
1478
+ _append_segment(current[role], y_seg, x_seg)
1479
+ else:
1480
+ _append_segment(current[role], x_seg, y_seg)
1481
+ continue
1482
+
1483
+ label = str(cycle_id) if first_segment else '_nolegend_'
1484
+ # Swap x and y if --ro flag is set
1485
+ if getattr(args, 'ro', False):
1486
+ ln, = ax.plot(y_seg, x_seg, '-', color=color, linewidth=2.0, label=label, alpha=0.8)
1487
+ else:
1488
+ ln, = ax.plot(x_seg, y_seg, '-', color=color, linewidth=2.0, label=label, alpha=0.8)
1489
+ current[role] = ln
1490
+
1491
+ if current['charge'] is not None and current['discharge'] is not None:
1492
+ cycle_id += 1
1493
+
1494
+ if cycle_lines.get(cycle_id) == {"charge": None, "discharge": None}:
1495
+ cycle_lines.pop(cycle_id, None)
1496
+
1497
+ # Labels with consistent labelpad (same as GC/CPC)
1498
+ # Swap axis labels if --ro flag is set
1499
+ if getattr(args, 'ro', False):
1500
+ ax.set_xlabel(y_label, labelpad=8.0)
1501
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
1502
+ else:
1503
+
1504
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
1505
+ ax.set_ylabel(y_label, labelpad=8.0)
1506
+ legend = ax.legend(title='Cycle')
1507
+ if legend is not None:
1508
+ try:
1509
+ legend.set_frame_on(False)
1510
+ except Exception:
1511
+ pass
1512
+ legend.get_title().set_fontsize('medium')
1513
+ # No background grid by default (same as GC)
1514
+
1515
+ # Adjust layout to ensure top and bottom labels/titles are visible (same as GC/CPC)
1516
+ fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
1517
+
1518
+ # Apply style file if provided
1519
+ if style_cfg:
1520
+ try:
1521
+ from .batch import _apply_ec_style
1522
+ _apply_ec_style(fig, ax, style_cfg)
1523
+ # Redraw after applying style
1524
+ if hasattr(fig, 'canvas'):
1525
+ fig.canvas.draw()
1526
+ except Exception as e:
1527
+ print(f"Warning: Error applying style file: {e}")
1528
+
1529
+ # Save if requested
1530
+ if len(data_files) > 1 and (args.savefig or args.out):
1531
+ # Multiple files: save to Figures/ directory
1532
+ base_name = os.path.splitext(os.path.basename(ec_file))[0]
1533
+ output_format = getattr(args, 'format', 'svg')
1534
+ outname = os.path.join(out_dir, f"{base_name}.{output_format}")
1535
+ else:
1536
+ outname = args.savefig or args.out
1537
+ if outname:
1538
+ if not _os.path.splitext(outname)[1]:
1539
+ outname += '.svg'
1540
+ _, _ext = _os.path.splitext(outname)
1541
+ if _ext.lower() == '.svg':
1542
+ try:
1543
+ _fig_fc = fig.get_facecolor()
1544
+ except Exception:
1545
+ _fig_fc = None
1546
+ try:
1547
+ _ax_fc = ax.get_facecolor()
1548
+ except Exception:
1549
+ _ax_fc = None
1550
+ try:
1551
+ if getattr(fig, 'patch', None) is not None:
1552
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
1553
+ if getattr(ax, 'patch', None) is not None:
1554
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
1555
+ except Exception:
1556
+ pass
1557
+ try:
1558
+ fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
1559
+ finally:
1560
+ try:
1561
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
1562
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
1563
+ except Exception:
1564
+ pass
1565
+ try:
1566
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
1567
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
1568
+ except Exception:
1569
+ pass
1570
+ else:
1571
+ fig.savefig(outname, dpi=300)
1572
+ print(f"dQ/dV plot saved to {outname} ({y_label})")
1573
+
1574
+ # Show / interactive
1575
+ if args.interactive:
1576
+ try:
1577
+ _backend = _plt.get_backend()
1578
+ except Exception:
1579
+ _backend = "unknown"
1580
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1581
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1582
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1583
+ if _is_noninteractive:
1584
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
1585
+ print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
1586
+ print(" export MPLBACKEND=MacOSX # built-in macOS backend")
1587
+ print(" export MPLBACKEND=TkAgg # if Tk is available")
1588
+ print(" export MPLBACKEND=QtAgg # if PyQt is installed")
1589
+ print("Or run without --interactive and use --out to save the figure.")
1590
+ else:
1591
+ try:
1592
+ _plt.ion()
1593
+ except Exception:
1594
+ pass
1595
+ _plt.show(block=False)
1596
+ try:
1597
+ fig._bp_source_paths = [_os.path.abspath(ec_file)]
1598
+ except Exception:
1599
+ pass
1600
+ try:
1601
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
1602
+ except Exception as _ie:
1603
+ print(f"Interactive menu failed: {_ie}")
1604
+ _plt.show()
1605
+ else:
1606
+ if not (args.savefig or args.out):
1607
+ try:
1608
+ _backend = _plt.get_backend()
1609
+ except Exception:
1610
+ _backend = "unknown"
1611
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1612
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1613
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1614
+ if not _is_noninteractive:
1615
+ _plt.show()
1616
+ else:
1617
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
1618
+ # For multiple files, close the figure and continue to next file
1619
+ if len(data_files) > 1:
1620
+ _plt.close(fig)
1621
+ continue
1622
+ else:
1623
+ exit()
1624
+ except Exception as _e:
1625
+ print(f"dQ/dV plot failed for {ec_file}: {_e}")
1626
+ if len(data_files) > 1:
1627
+ continue
1628
+ else:
1629
+ exit(1)
1630
+ # Exit after processing all files
1631
+ if len(data_files) > 1:
1632
+ print(f"Processed {len(data_files)} dQ/dV files.")
1633
+ exit(0)
1634
+
1635
+ # Operando contour plotting mode (folder-based)
1636
+ if getattr(args, 'operando', False):
1637
+ import os as _os
1638
+ import matplotlib.pyplot as _plt
1639
+ try:
1640
+ # Determine target folder: explicit folder arg or current directory
1641
+ if len(args.files) == 0:
1642
+ folder = os.getcwd()
1643
+ elif len(args.files) == 1 and _os.path.isdir(args.files[0]):
1644
+ folder = _os.path.abspath(args.files[0])
1645
+ elif len(args.files) == 1 and not _os.path.isdir(args.files[0]):
1646
+ print("Operando mode expects a folder (or no argument to use current folder).")
1647
+ exit(1)
1648
+ else:
1649
+ print("Operando mode: provide at most one folder or no argument.")
1650
+ exit(1)
1651
+
1652
+ # Build plot
1653
+ fig, ax, meta = plot_operando_folder(folder, args)
1654
+ im = meta.get('imshow')
1655
+ cbar = meta.get('colorbar')
1656
+ has_ec = bool(meta.get('has_ec'))
1657
+ ec_ax = meta.get('ec_ax') if has_ec else None
1658
+
1659
+ # Save if requested
1660
+ outname = args.savefig or args.out
1661
+ if outname:
1662
+ if not _os.path.splitext(outname)[1]:
1663
+ outname += '.svg'
1664
+ _, _ext = _os.path.splitext(outname)
1665
+ if _ext.lower() == '.svg':
1666
+ try:
1667
+ _fig_fc = fig.get_facecolor()
1668
+ except Exception:
1669
+ _fig_fc = None
1670
+ try:
1671
+ _ax_fc = ax.get_facecolor()
1672
+ except Exception:
1673
+ _ax_fc = None
1674
+ try:
1675
+ if getattr(fig, 'patch', None) is not None:
1676
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
1677
+ if getattr(ax, 'patch', None) is not None:
1678
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
1679
+ if ec_ax is not None and getattr(ec_ax, 'patch', None) is not None:
1680
+ ec_ax.patch.set_alpha(0.0); ec_ax.patch.set_facecolor('none')
1681
+ except Exception:
1682
+ pass
1683
+ try:
1684
+ fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
1685
+ finally:
1686
+ try:
1687
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
1688
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
1689
+ except Exception:
1690
+ pass
1691
+ try:
1692
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
1693
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
1694
+ except Exception:
1695
+ pass
1696
+ else:
1697
+ fig.savefig(outname, dpi=300)
1698
+ print(f"Operando plot saved to {outname}")
1699
+
1700
+ # Interactive or show
1701
+ if args.interactive:
1702
+ try:
1703
+ _backend = _plt.get_backend()
1704
+ except Exception:
1705
+ _backend = "unknown"
1706
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1707
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1708
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1709
+ if _is_noninteractive:
1710
+ print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
1711
+ print("Tips: unset MPLBACKEND or set a GUI backend")
1712
+ print("Or run without --interactive and use --out to save the figure.")
1713
+ else:
1714
+ try:
1715
+ _plt.ion()
1716
+ except Exception:
1717
+ pass
1718
+ try:
1719
+ _plt.show(block=False)
1720
+ except Exception:
1721
+ pass
1722
+ try:
1723
+ # Call interactive menu regardless of EC presence
1724
+ # When ec_ax is None, EC-related commands will be disabled
1725
+ if operando_ec_interactive_menu is not None:
1726
+ operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=args.files)
1727
+ else:
1728
+ print("Interactive menu not available.")
1729
+ except Exception as _ie:
1730
+ print(f"Interactive menu failed: {_ie}")
1731
+ _plt.show()
1732
+ else:
1733
+ if not (args.savefig or args.out):
1734
+ try:
1735
+ _backend = _plt.get_backend()
1736
+ except Exception:
1737
+ _backend = "unknown"
1738
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
1739
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
1740
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
1741
+ if not _is_noninteractive:
1742
+ _plt.show()
1743
+ else:
1744
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
1745
+ exit()
1746
+ except Exception as _e:
1747
+ print(f"Operando plot failed: {_e}")
1748
+ exit(1)
1749
+
1750
+ _maybe_expand_allfiles_argument(args, ec_mode_active)
1751
+
1752
+ if len(args.files) == 1:
1753
+ sole = args.files[0]
1754
+ if sole.lower() == 'all':
1755
+ batch_process(os.getcwd(), args)
1756
+ exit()
1757
+ elif sole.lower() == 'allfiles':
1758
+ _prepare_allfiles_directory(os.getcwd(), args, use_relative_paths=True)
1759
+ # Continue to normal plotting mode with all files
1760
+ elif os.path.isdir(sole):
1761
+ batch_process(os.path.abspath(sole), args)
1762
+ exit()
1763
+
1764
+ # --- XY Batch Mode: check for --all flag for XY files ---
1765
+ # Handle --all flag for XY batch processing (consistent with EC batch mode)
1766
+ if not ec_mode_active and getattr(args, 'all', None) is not None:
1767
+ batch_process(os.getcwd(), args)
1768
+ exit()
1769
+
1770
+ # ---------------- Normal (multi-file) path continues below ----------------
1771
+ # Apply conditional default for delta (normal mode only)
1772
+ if args.delta is None:
1773
+ args.delta = 0.1 if args.stack else 0.0
1774
+
1775
+ # ---------------- Automatic session (.pkl) load shortcut ----------------
1776
+ # If user invokes: batplot session.pkl [--interactive]
1777
+ if len(args.files) == 1 and args.files[0].lower().endswith('.pkl'):
1778
+ sess_path = args.files[0]
1779
+ if not os.path.isfile(sess_path):
1780
+ print(f"Session file not found: {sess_path}")
1781
+ exit(1)
1782
+ try:
1783
+ with open(sess_path, 'rb') as f:
1784
+ sess = pickle.load(f)
1785
+ if not isinstance(sess, dict) or 'version' not in sess:
1786
+ print("Not a valid batplot session file.")
1787
+ exit(1)
1788
+ except Exception as e:
1789
+ print(f"Failed to load session: {e}")
1790
+ exit(1)
1791
+ # If it's an EC GC session, load and open EC interactive menu directly
1792
+ if isinstance(sess, dict) and sess.get('kind') == 'ec_gc':
1793
+ try:
1794
+ import matplotlib.pyplot as _plt
1795
+ res = load_ec_session(sess_path)
1796
+ if not res:
1797
+ print("Failed to load EC session.")
1798
+ exit(1)
1799
+ fig, ax, cycle_lines = res
1800
+ try:
1801
+ _plt.ion()
1802
+ except Exception:
1803
+ pass
1804
+ try:
1805
+ _plt.show(block=False)
1806
+ except Exception:
1807
+ pass
1808
+ try:
1809
+ source_list = list(getattr(fig, '_bp_source_paths', []) or [])
1810
+ sess_abs = os.path.abspath(sess_path)
1811
+ if sess_abs not in source_list:
1812
+ source_list.append(sess_abs)
1813
+ fig._bp_source_paths = source_list
1814
+ except Exception:
1815
+ pass
1816
+ try:
1817
+ electrochem_interactive_menu(fig, ax, cycle_lines, file_path=sess_path)
1818
+ except Exception as _ie:
1819
+ print(f"Interactive menu failed: {_ie}")
1820
+ _plt.show()
1821
+ exit()
1822
+ except Exception as e:
1823
+ print(f"EC session load failed: {e}")
1824
+ exit(1)
1825
+ # If it's an operando+EC session, load and open the combined interactive menu
1826
+ if isinstance(sess, dict) and sess.get('kind') == 'operando_ec':
1827
+ try:
1828
+ import matplotlib.pyplot as _plt
1829
+ res = load_operando_session(sess_path)
1830
+ if not res:
1831
+ print("Failed to load operando+EC session.")
1832
+ exit(1)
1833
+ fig2, ax2, im2, cbar2, ec_ax2 = res
1834
+ # Always open interactive menu for session files
1835
+ try:
1836
+ _plt.ion()
1837
+ except Exception:
1838
+ pass
1839
+ try:
1840
+ _plt.show(block=False)
1841
+ except Exception:
1842
+ pass
1843
+ try:
1844
+ if operando_ec_interactive_menu is not None:
1845
+ operando_ec_interactive_menu(fig2, ax2, im2, cbar2, ec_ax2)
1846
+ except Exception as _ie:
1847
+ print(f"Interactive menu failed: {_ie}")
1848
+ _plt.show()
1849
+ exit()
1850
+ except Exception as e:
1851
+ print(f"Operando+EC session load failed: {e}")
1852
+ exit(1)
1853
+
1854
+ # If it's a CPC session, load and open CPC interactive menu
1855
+ if isinstance(sess, dict) and sess.get('kind') == 'cpc':
1856
+ try:
1857
+ import matplotlib.pyplot as _plt
1858
+ res = load_cpc_session(sess_path)
1859
+ if not res:
1860
+ print("Failed to load CPC session.")
1861
+ exit(1)
1862
+ fig_c, ax_c, ax2_c, sc_c, sc_d, sc_e, file_data = res
1863
+ try:
1864
+ _plt.ion()
1865
+ except Exception:
1866
+ pass
1867
+ try:
1868
+ _plt.show(block=False)
1869
+ except Exception:
1870
+ pass
1871
+ try:
1872
+ if cpc_interactive_menu is not None:
1873
+ cpc_interactive_menu(fig_c, ax_c, ax2_c, sc_c, sc_d, sc_e, file_data=file_data)
1874
+ except Exception as _ie:
1875
+ print(f"CPC interactive menu failed: {_ie}")
1876
+ _plt.show()
1877
+ exit()
1878
+ except Exception as e:
1879
+ print(f"CPC session load failed: {e}")
1880
+ exit(1)
1881
+
1882
+ # Reconstruct minimal state and go to interactive if requested
1883
+ plt.ion() if args.interactive else None
1884
+ fig, ax = plt.subplots(figsize=(8,6))
1885
+ y_data_list = []
1886
+ x_data_list = []
1887
+ labels_list = []
1888
+ orig_y = []
1889
+ label_text_objects = []
1890
+ x_full_list = []
1891
+ raw_y_full_list = []
1892
+ offsets_list = []
1893
+ tick_state = {
1894
+ 'bx': True,'tx': False,'ly': True,'ry': False,
1895
+ 'mbx': False,'mtx': False,'mly': False,'mry': False
1896
+ }
1897
+ saved_stack = bool(sess.get('args_subset', {}).get('stack', False))
1898
+ # Pull data
1899
+ # --- Robust reconstruction of stored curves ---
1900
+ x_loaded = sess.get('x_data', [])
1901
+ y_loaded = sess.get('y_data', []) # stored plotted (baseline+offset) values
1902
+ orig_loaded = sess.get('orig_y', []) # stored baseline (normalized/raw w/out offsets)
1903
+ offsets_saved = sess.get('offsets', [])
1904
+ n_curves = len(x_loaded)
1905
+ for i in range(n_curves):
1906
+ x_arr = np.array(x_loaded[i])
1907
+ off = offsets_saved[i] if i < len(offsets_saved) else 0.0
1908
+ if orig_loaded and i < len(orig_loaded):
1909
+ base = np.array(orig_loaded[i])
1910
+ else:
1911
+ # Fallback: derive baseline by subtracting offset from stored y (handles legacy sessions)
1912
+ y_arr_full = np.array(y_loaded[i]) if i < len(y_loaded) else np.array([])
1913
+ base = y_arr_full - off
1914
+ y_plot = base + off
1915
+ x_data_list.append(x_arr)
1916
+ orig_y.append(base)
1917
+ y_data_list.append(y_plot)
1918
+ ax.plot(x_arr, y_plot, lw=1)
1919
+ x_full_list.append(x_arr.copy())
1920
+ raw_y_full_list.append(base.copy())
1921
+ offsets_list[:] = offsets_saved if offsets_saved else [0.0]*n_curves
1922
+ try:
1923
+ axes_bbox = sess.get('figure', {}).get('axes_bbox')
1924
+ if _session_apply_axes_bbox(ax, axes_bbox):
1925
+ try:
1926
+ fig._skip_initial_text_visibility = True
1927
+ except Exception:
1928
+ pass
1929
+ except Exception:
1930
+ pass
1931
+ # Apply stored line styles (if any)
1932
+ try:
1933
+ stored_styles = sess.get('line_styles', [])
1934
+ for ln, st in zip(ax.lines, stored_styles):
1935
+ if 'color' in st: ln.set_color(st['color'])
1936
+ if 'linewidth' in st: ln.set_linewidth(st['linewidth'])
1937
+ if 'linestyle' in st:
1938
+ try: ln.set_linestyle(st['linestyle'])
1939
+ except Exception: pass
1940
+ if 'alpha' in st and st['alpha'] is not None: ln.set_alpha(st['alpha'])
1941
+ if 'marker' in st and st['marker'] is not None:
1942
+ try: ln.set_marker(st['marker'])
1943
+ except Exception: pass
1944
+ if 'markersize' in st and st['markersize'] is not None:
1945
+ try: ln.set_markersize(st['markersize'])
1946
+ except Exception: pass
1947
+ if 'markerfacecolor' in st and st['markerfacecolor'] is not None:
1948
+ try: ln.set_markerfacecolor(st['markerfacecolor'])
1949
+ except Exception: pass
1950
+ if 'markeredgecolor' in st and st['markeredgecolor'] is not None:
1951
+ try: ln.set_markeredgecolor(st['markeredgecolor'])
1952
+ except Exception: pass
1953
+ except Exception:
1954
+ pass
1955
+ labels_list[:] = sess.get('labels', [f"Curve {i+1}" for i in range(len(y_data_list))])
1956
+ delta = sess.get('delta', 0.0)
1957
+ ax.set_xlabel(sess.get('axis', {}).get('xlabel', 'X'))
1958
+ ax.set_ylabel(sess.get('axis', {}).get('ylabel', 'Intensity'))
1959
+
1960
+ # Restore normalization ranges (if saved)
1961
+ axis_cfg = sess.get('axis', {})
1962
+ if 'norm_xlim' in axis_cfg and axis_cfg['norm_xlim'] is not None:
1963
+ ax._norm_xlim = tuple(axis_cfg['norm_xlim'])
1964
+ if 'norm_ylim' in axis_cfg and axis_cfg['norm_ylim'] is not None:
1965
+ ax._norm_ylim = tuple(axis_cfg['norm_ylim'])
1966
+
1967
+ # Restore display limits
1968
+ if 'xlim' in axis_cfg:
1969
+ ax.set_xlim(*axis_cfg['xlim'])
1970
+ if 'ylim' in axis_cfg:
1971
+ ax.set_ylim(*axis_cfg['ylim'])
1972
+ # Apply figure size & dpi if stored
1973
+ fig_cfg = sess.get('figure', {})
1974
+ try:
1975
+ if fig_cfg.get('size') and isinstance(fig_cfg['size'], (list, tuple)) and len(fig_cfg['size']) == 2:
1976
+ fw, fh = fig_cfg['size']
1977
+ if not globals().get('keep_canvas_fixed', True):
1978
+ fig.set_size_inches(float(fw), float(fh), forward=True)
1979
+ else:
1980
+ # Keep canvas size as current; avoid surprising resize on load
1981
+ pass
1982
+ # Don't restore saved DPI - use system default to avoid display-dependent issues
1983
+ # (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
1984
+ # Keeping figure size in inches ensures consistent appearance across platforms
1985
+ except Exception:
1986
+ pass
1987
+ # Restore spines (linewidth, color, visibility) and subplot margins/tick widths (for CLI .pkl load)
1988
+ try:
1989
+ spine_specs = fig_cfg.get('spines', {})
1990
+ if spine_specs:
1991
+ for name, spec in spine_specs.items():
1992
+ spn = ax.spines.get(name)
1993
+ if not spn: continue
1994
+ if 'linewidth' in spec: spn.set_linewidth(spec['linewidth'])
1995
+ if 'color' in spec and spec['color'] is not None: spn.set_edgecolor(spec['color'])
1996
+ if 'visible' in spec: spn.set_visible(bool(spec['visible']))
1997
+ else:
1998
+ # legacy fallback
1999
+ legacy_vis = fig_cfg.get('spine_vis', {})
2000
+ for name, vis in legacy_vis.items():
2001
+ spn = ax.spines.get(name)
2002
+ if spn:
2003
+ spn.set_visible(bool(vis))
2004
+ spm = fig_cfg.get('subplot_margins')
2005
+ if spm and all(k in spm for k in ('left','right','bottom','top')):
2006
+ fig.subplots_adjust(left=spm['left'], right=spm['right'], bottom=spm['bottom'], top=spm['top'])
2007
+ try:
2008
+ fig._skip_initial_text_visibility = True
2009
+ except Exception:
2010
+ pass
2011
+
2012
+ # Restore exact frame size if stored (for precision)
2013
+ frame_size = fig_cfg.get('frame_size')
2014
+ if frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
2015
+ target_w_in, target_h_in = map(float, frame_size)
2016
+ # Get current canvas size
2017
+ canvas_w_in, canvas_h_in = fig.get_size_inches()
2018
+ # Calculate needed fractions to achieve exact frame size
2019
+ if canvas_w_in > 0 and canvas_h_in > 0:
2020
+ # Get current position to preserve centering
2021
+ bbox = ax.get_position()
2022
+ center_x = (bbox.x0 + bbox.x1) / 2.0
2023
+ center_y = (bbox.y0 + bbox.y1) / 2.0
2024
+ # Calculate new fractions
2025
+ new_w_frac = target_w_in / canvas_w_in
2026
+ new_h_frac = target_h_in / canvas_h_in
2027
+ # Reposition to maintain centering
2028
+ new_left = center_x - new_w_frac / 2.0
2029
+ new_right = center_x + new_w_frac / 2.0
2030
+ new_bottom = center_y - new_h_frac / 2.0
2031
+ new_top = center_y + new_h_frac / 2.0
2032
+ # Apply
2033
+ fig.subplots_adjust(left=new_left, right=new_right, bottom=new_bottom, top=new_top)
2034
+ try:
2035
+ fig._skip_initial_text_visibility = True
2036
+ except Exception:
2037
+ pass
2038
+ except Exception:
2039
+ pass
2040
+ # Font
2041
+ font_cfg = sess.get('font', {})
2042
+ if font_cfg.get('chain'):
2043
+ plt.rcParams['font.family'] = 'sans-serif'
2044
+ plt.rcParams['font.sans-serif'] = font_cfg['chain']
2045
+ if font_cfg.get('size'):
2046
+ plt.rcParams['font.size'] = font_cfg['size']
2047
+ # Tick state restore
2048
+ saved_tick = sess.get('tick_state', {})
2049
+ for k,v in saved_tick.items():
2050
+ if k in tick_state: tick_state[k] = v
2051
+ # Persist on axes for interactive menu initialization
2052
+ try:
2053
+ ax._saved_tick_state = dict(tick_state)
2054
+ except Exception:
2055
+ pass
2056
+ # Tick widths restore
2057
+ try:
2058
+ tw = sess.get('tick_widths', {})
2059
+ if tw.get('x_major') is not None:
2060
+ ax.tick_params(axis='x', which='major', width=float(tw['x_major']))
2061
+ if tw.get('x_minor') is not None:
2062
+ ax.tick_params(axis='x', which='minor', width=float(tw['x_minor']))
2063
+ if tw.get('y_major') is not None:
2064
+ ax.tick_params(axis='y', which='major', width=float(tw['y_major']))
2065
+ if tw.get('y_minor') is not None:
2066
+ ax.tick_params(axis='y', which='minor', width=float(tw['y_minor']))
2067
+ except Exception:
2068
+ pass
2069
+ # Tick lengths restore
2070
+ try:
2071
+ tl = sess.get('tick_lengths', {})
2072
+ if tl.get('x_major') is not None or tl.get('y_major') is not None:
2073
+ major_len = tl.get('x_major') or tl.get('y_major')
2074
+ ax.tick_params(axis='both', which='major', length=major_len)
2075
+ if not hasattr(fig, '_tick_lengths'):
2076
+ fig._tick_lengths = {}
2077
+ fig._tick_lengths['major'] = major_len
2078
+ if tl.get('x_minor') is not None or tl.get('y_minor') is not None:
2079
+ minor_len = tl.get('x_minor') or tl.get('y_minor')
2080
+ ax.tick_params(axis='both', which='minor', length=minor_len)
2081
+ if not hasattr(fig, '_tick_lengths'):
2082
+ fig._tick_lengths = {}
2083
+ fig._tick_lengths['minor'] = minor_len
2084
+ except Exception:
2085
+ pass
2086
+
2087
+ # Restore WASD state (spine, ticks, labels, title visibility for all 4 sides)
2088
+ try:
2089
+ wasd = sess.get('wasd_state', {})
2090
+ if wasd:
2091
+ # Store the xlabel/ylabel before applying WASD (to restore hidden titles later if needed)
2092
+ stored_xlabel = ax.get_xlabel()
2093
+ stored_ylabel = ax.get_ylabel()
2094
+
2095
+ # Apply spine visibility
2096
+ for side in ('top', 'bottom', 'left', 'right'):
2097
+ state = wasd.get(side, {})
2098
+ sp = ax.spines.get(side)
2099
+ if sp and 'spine' in state:
2100
+ sp.set_visible(bool(state['spine']))
2101
+
2102
+ # Apply tick and label visibility
2103
+ for side in ('top', 'bottom', 'left', 'right'):
2104
+ state = wasd.get(side, {})
2105
+ if side in ('top', 'bottom'):
2106
+ # X-axis ticks
2107
+ tick_key = 'tick1On' if side == 'top' else 'tick2On'
2108
+ label_key = 'label1On' if side == 'top' else 'label2On'
2109
+ if 'ticks' in state:
2110
+ ax.tick_params(axis='x', which='major', **{tick_key: bool(state['ticks'])})
2111
+ if 'labels' in state:
2112
+ ax.tick_params(axis='x', which='major', **{label_key: bool(state['labels'])})
2113
+ if 'minor' in state:
2114
+ ax.tick_params(axis='x', which='minor', **{tick_key: bool(state['minor'])})
2115
+ else:
2116
+ # Y-axis ticks
2117
+ tick_key = 'tick1On' if side == 'left' else 'tick2On'
2118
+ label_key = 'label1On' if side == 'left' else 'label2On'
2119
+ if 'ticks' in state:
2120
+ ax.tick_params(axis='y', which='major', **{tick_key: bool(state['ticks'])})
2121
+ if 'labels' in state:
2122
+ ax.tick_params(axis='y', which='major', **{label_key: bool(state['labels'])})
2123
+ if 'minor' in state:
2124
+ ax.tick_params(axis='y', which='minor', **{tick_key: bool(state['minor'])})
2125
+
2126
+ # Apply title visibility - CRITICAL: Check title state before restoring labels
2127
+ # Bottom xlabel
2128
+ bottom_title_on = wasd.get('bottom', {}).get('title', True)
2129
+ if bottom_title_on:
2130
+ ax.set_xlabel(stored_xlabel)
2131
+ else:
2132
+
2133
+ ax.set_xlabel('') # Hidden by user via s5
2134
+ # Store the hidden label for later restoration
2135
+ if stored_xlabel:
2136
+ setattr(ax, '_stored_xlabel', stored_xlabel)
2137
+
2138
+ # Left ylabel
2139
+ left_title_on = wasd.get('left', {}).get('title', True)
2140
+ if left_title_on:
2141
+ ax.set_ylabel(stored_ylabel)
2142
+ else:
2143
+
2144
+ ax.set_ylabel('') # Hidden by user via a5
2145
+ # Store the hidden label for later restoration
2146
+ if stored_ylabel:
2147
+ setattr(ax, '_stored_ylabel', stored_ylabel)
2148
+
2149
+ # Top xlabel (if exists)
2150
+ top_title_on = wasd.get('top', {}).get('title', False)
2151
+ setattr(ax, '_top_xlabel_on', top_title_on)
2152
+
2153
+ # Right ylabel (if exists)
2154
+ right_title_on = wasd.get('right', {}).get('title', False)
2155
+ setattr(ax, '_right_ylabel_on', right_title_on)
2156
+ except Exception as e:
2157
+ # Don't fail session load if WASD restoration fails
2158
+ print(f"Warning: Could not fully restore WASD state: {e}")
2159
+
2160
+ # Rebuild label texts
2161
+ for i, lab in enumerate(labels_list):
2162
+ txt = ax.text(1.0, 1.0, f"{i+1}: {lab}", ha='right', va='top', transform=ax.transAxes,
2163
+ fontsize=plt.rcParams.get('font.size', 16))
2164
+ label_text_objects.append(txt)
2165
+ # Restore curve names visibility
2166
+ try:
2167
+ curve_names_visible = bool(sess.get('curve_names_visible', True))
2168
+ for txt in label_text_objects:
2169
+ txt.set_visible(curve_names_visible)
2170
+ fig._curve_names_visible = curve_names_visible
2171
+ except Exception:
2172
+ pass
2173
+ # Restore stack label position preference
2174
+ try:
2175
+ stack_label_at_bottom = bool(sess.get('stack_label_at_bottom', False))
2176
+ fig._stack_label_at_bottom = stack_label_at_bottom
2177
+ except Exception:
2178
+ pass
2179
+ try:
2180
+ fig._label_anchor_left = bool(sess.get('label_anchor_left', False))
2181
+ except Exception:
2182
+ pass
2183
+ # Restore grid state
2184
+ try:
2185
+ grid_state = bool(sess.get('grid', False))
2186
+ if grid_state:
2187
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
2188
+ else:
2189
+ ax.grid(False)
2190
+ except Exception:
2191
+ pass
2192
+ # CIF tick series (optional)
2193
+ cif_tick_series = sess.get('cif_tick_series') or []
2194
+ cif_hkl_map = {k: [tuple(v) for v in val] for k,val in sess.get('cif_hkl_map', {}).items()}
2195
+ cif_hkl_label_map = {k: dict(v) for k,v in sess.get('cif_hkl_label_map', {}).items()}
2196
+ cif_numbering_enabled = True
2197
+ cif_extend_suspended = False
2198
+ show_cif_hkl = sess.get('show_cif_hkl', False)
2199
+ show_cif_titles = sess.get('show_cif_titles', True)
2200
+ # Provide minimal stubs to satisfy interactive menu dependencies
2201
+ # Axis mode restoration informs downstream toggles (e.g., CIF conversions, crosshair availability)
2202
+ axis_mode_restored = sess.get('axis_mode', 'unknown')
2203
+ use_Q = axis_mode_restored == 'Q'
2204
+ use_r = axis_mode_restored == 'r'
2205
+ use_E = axis_mode_restored == 'energy'
2206
+ use_k = axis_mode_restored == 'k'
2207
+ use_rft = axis_mode_restored == 'rft'
2208
+ use_2th = axis_mode_restored == '2theta'
2209
+ x_label = ax.get_xlabel() or 'X'
2210
+ def update_tick_visibility_local():
2211
+ # Major ticks/labels
2212
+ ax.tick_params(axis='x', bottom=tick_state['bx'], top=tick_state['tx'], labelbottom=tick_state['bx'], labeltop=tick_state['tx'])
2213
+ ax.tick_params(axis='y', left=tick_state['ly'], right=tick_state['ry'], labelleft=tick_state['ly'], labelright=tick_state['ry'])
2214
+ # Minor ticks
2215
+ if tick_state.get('mbx') or tick_state.get('mtx'):
2216
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
2217
+ ax.xaxis.set_minor_formatter(NullFormatter())
2218
+ ax.tick_params(axis='x', which='minor', bottom=tick_state.get('mbx', False), top=tick_state.get('mtx', False), labelbottom=False, labeltop=False)
2219
+ else:
2220
+ ax.tick_params(axis='x', which='minor', bottom=False, top=False, labelbottom=False, labeltop=False)
2221
+ if tick_state.get('mly') or tick_state.get('mry'):
2222
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
2223
+ ax.yaxis.set_minor_formatter(NullFormatter())
2224
+ ax.tick_params(axis='y', which='minor', left=tick_state.get('mly', False), right=tick_state.get('mry', False), labelleft=False, labelright=False)
2225
+ else:
2226
+ ax.tick_params(axis='y', which='minor', left=False, right=False, labelleft=False, labelright=False)
2227
+ update_tick_visibility_local()
2228
+ # Ensure label positions correct
2229
+ stack_label_bottom = bool(sess.get('stack_label_at_bottom', False))
2230
+ update_labels(ax, y_data_list, label_text_objects, saved_stack, stack_label_bottom)
2231
+ if cif_tick_series:
2232
+ # Provide draw/extend helpers compatible with interactive menu using original placement logic
2233
+ def _session_q_to_2theta(peaksQ, wl):
2234
+ if wl is None:
2235
+ return []
2236
+ out = []
2237
+ for q in peaksQ:
2238
+ s = q*wl/(4*np.pi)
2239
+ if 0 <= s < 1:
2240
+ out.append(np.degrees(2*np.arcsin(s)))
2241
+ return out
2242
+
2243
+ def _session_ensure_wavelength(default_wl=1.5406):
2244
+ # Prefer any stored wl, else args.wl, else provided default
2245
+ for _lab,_fname,_peaks,_wl,_qmax,_color in cif_tick_series:
2246
+ if _wl is not None:
2247
+ return _wl
2248
+ return getattr(args, 'wl', None) or default_wl
2249
+
2250
+ def _session_cif_extend(xmax_domain):
2251
+ # Minimal extension: do nothing (could replicate original if needed)
2252
+ return
2253
+
2254
+ def _session_cif_draw():
2255
+ if not cif_tick_series:
2256
+ return
2257
+ try:
2258
+ # Preserve both x and y-axis limits to prevent movement when toggling
2259
+ prev_xlim = ax.get_xlim()
2260
+ prev_ylim = ax.get_ylim()
2261
+ # Use preserved y-axis limits for calculations to prevent incremental movement
2262
+ orig_ylim = prev_ylim
2263
+ orig_yr = orig_ylim[1] - orig_ylim[0]
2264
+ if orig_yr <= 0: orig_yr = 1.0
2265
+ # Check visibility flag first
2266
+ show_titles_local = bool(show_cif_titles) # Use closure variable from outer scope
2267
+ # Also check figure attribute and module attribute as fallback
2268
+ try:
2269
+ # Check figure attribute first (from interactive menu)
2270
+ if hasattr(fig, '_bp_show_cif_titles'):
2271
+ show_titles_local = bool(getattr(fig, '_bp_show_cif_titles', show_titles_local))
2272
+ # Check __main__ module (for backward compatibility)
2273
+ _bp_module = sys.modules.get('__main__')
2274
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_titles'):
2275
+ show_titles_local = bool(getattr(_bp_module, 'show_cif_titles', show_titles_local))
2276
+ except Exception:
2277
+ pass
2278
+ # Calculate base and spacing based on original y-axis limits
2279
+ if saved_stack or len(y_data_list) > 1:
2280
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
2281
+ base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
2282
+ else:
2283
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2284
+ base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
2285
+ # Only adjust y-axis limits if titles are visible
2286
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
2287
+ cur_ylim = ax.get_ylim()
2288
+ yr = cur_ylim[1] - cur_ylim[0]
2289
+ if yr <= 0: yr = 1.0
2290
+ if show_titles_local and needed_min < orig_ylim[0]:
2291
+ # Expand y-axis only if needed, using original limits as reference
2292
+ ax.set_ylim(needed_min, orig_ylim[1])
2293
+ cur_ylim = ax.get_ylim()
2294
+ yr = cur_ylim[1] - cur_ylim[0]
2295
+ if yr <= 0: yr = 1.0
2296
+ # Recalculate base with new limits if we expanded
2297
+ if saved_stack or len(y_data_list) > 1:
2298
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
2299
+ base = global_min - 0.08*yr; spacing = 0.05*yr
2300
+ else:
2301
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2302
+ base = global_min - 0.06*yr; spacing = 0.04*yr
2303
+ # Clear previous artifacts
2304
+ for art in getattr(ax, '_cif_tick_art', []):
2305
+ try: art.remove()
2306
+ except Exception: pass
2307
+ new_art = []
2308
+ show_hkl_local = bool(show_cif_hkl)
2309
+ wl_any = _session_ensure_wavelength()
2310
+ # Draw each series
2311
+ for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
2312
+ y_line = base - i*spacing
2313
+ # Convert peaks to axis domain
2314
+ if use_2th:
2315
+ wl_use = wl if wl is not None else wl_any
2316
+ domain_peaks = _session_q_to_2theta(peaksQ, wl_use)
2317
+ else:
2318
+ domain_peaks = peaksQ
2319
+ # Clip to visible x-range
2320
+ xlow,xhigh = ax.get_xlim()
2321
+ domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
2322
+ # Build hkl label map (keys are Q values, not 2θ)
2323
+ label_map = cif_hkl_label_map.get(fname, {}) if show_hkl_local else {}
2324
+ if show_hkl_local and len(domain_peaks) > 4000:
2325
+ show_hkl_local = False # safety
2326
+ for p in domain_peaks:
2327
+ ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
2328
+ new_art.append(ln)
2329
+ if show_hkl_local:
2330
+ # When axis is 2θ convert back to Q to look up hkl label
2331
+ if use_2th and (wl or wl_any):
2332
+ theta = np.radians(p/2.0)
2333
+ Qp = 4*np.pi*np.sin(theta)/(wl if wl is not None else wl_any)
2334
+ else:
2335
+ Qp = p
2336
+ lbl = label_map.get(round(Qp,6))
2337
+ if lbl:
2338
+ t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
2339
+ new_art.append(t_hkl)
2340
+ # Removed numbering prefix; keep one leading space for padding from axis
2341
+ # Only add title label if show_cif_titles is True
2342
+ if show_titles_local:
2343
+ label_text = f" {lab}"
2344
+ txt = ax.text(prev_xlim[0], y_line+0.005*yr, label_text,
2345
+ ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',16))), color=color)
2346
+ new_art.append(txt)
2347
+ ax._cif_tick_art = new_art
2348
+ # Restore both x and y-axis limits to prevent movement
2349
+ ax.set_xlim(prev_xlim)
2350
+ # Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
2351
+ if not show_titles_local:
2352
+ # Titles hidden: always restore original limits
2353
+ ax.set_ylim(prev_ylim)
2354
+ elif needed_min >= prev_ylim[0]:
2355
+ # Titles shown but no expansion needed: restore original limits
2356
+ ax.set_ylim(prev_ylim)
2357
+ # Otherwise, keep the expanded limits (already set above)
2358
+ fig.canvas.draw_idle()
2359
+ except Exception:
2360
+ pass
2361
+ ax._cif_extend_func = _session_cif_extend
2362
+ ax._cif_draw_func = _session_cif_draw
2363
+ ax._cif_draw_func()
2364
+
2365
+ # Restore axis title duplicates/visibility exactly as saved
2366
+ titles = sess.get('axis_titles', {})
2367
+ title_texts = sess.get('axis_title_texts', {})
2368
+ bottom_text = title_texts.get('bottom_x') or title_texts.get('bottom')
2369
+ left_text = title_texts.get('left_y') or title_texts.get('left')
2370
+ top_text = title_texts.get('top_x') or title_texts.get('top')
2371
+ right_text = title_texts.get('right_y') or title_texts.get('right')
2372
+ try:
2373
+ if bottom_text is not None:
2374
+ ax._stored_xlabel = bottom_text
2375
+ if left_text is not None:
2376
+ ax._stored_ylabel = left_text
2377
+ if top_text:
2378
+ ax._top_xlabel_text_override = top_text
2379
+ elif hasattr(ax, '_top_xlabel_text_override'):
2380
+ delattr(ax, '_top_xlabel_text_override')
2381
+ if right_text:
2382
+ ax._right_ylabel_text_override = right_text
2383
+ elif hasattr(ax, '_right_ylabel_text_override'):
2384
+ delattr(ax, '_right_ylabel_text_override')
2385
+ # Bottom X title
2386
+ if titles.get('has_bottom_x') is False:
2387
+ ax.xaxis.label.set_visible(False)
2388
+ else:
2389
+ ax.xaxis.label.set_visible(True)
2390
+ if bottom_text is not None:
2391
+ ax.set_xlabel(bottom_text)
2392
+ elif hasattr(ax, '_stored_xlabel'):
2393
+ ax.set_xlabel(ax._stored_xlabel)
2394
+ try:
2395
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
2396
+ except Exception:
2397
+ pass
2398
+ # Left Y title
2399
+ if titles.get('has_left_y') is False:
2400
+ ax.yaxis.label.set_visible(False)
2401
+ else:
2402
+ ax.yaxis.label.set_visible(True)
2403
+ if left_text is not None:
2404
+ ax.set_ylabel(left_text)
2405
+ elif hasattr(ax, '_stored_ylabel'):
2406
+ ax.set_ylabel(ax._stored_ylabel)
2407
+ try:
2408
+ _ui_position_left_ylabel(ax, fig, tick_state)
2409
+ except Exception:
2410
+ pass
2411
+ # Top X duplicate
2412
+ ax._top_xlabel_on = bool(titles.get('top_x', False))
2413
+ try:
2414
+ _ui_position_top_xlabel(ax, fig, tick_state)
2415
+ except Exception:
2416
+ pass
2417
+ if not ax._top_xlabel_on and hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
2418
+ try:
2419
+ ax._top_xlabel_artist.set_visible(False)
2420
+ except Exception:
2421
+ pass
2422
+ # Right Y duplicate
2423
+ ax._right_ylabel_on = bool(titles.get('right_y', False))
2424
+ try:
2425
+ _ui_position_right_ylabel(ax, fig, tick_state)
2426
+ except Exception:
2427
+ pass
2428
+ if not ax._right_ylabel_on and hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
2429
+ try:
2430
+ ax._right_ylabel_artist.set_visible(False)
2431
+ except Exception:
2432
+ pass
2433
+ except Exception:
2434
+ pass
2435
+ # Always open interactive menu for session files
2436
+ try:
2437
+ args.stack = saved_stack
2438
+ except Exception:
2439
+ pass
2440
+ # Restore autoscale/raw flags for consistent behavior with saved session
2441
+ try:
2442
+ args_subset = sess.get('args_subset', {})
2443
+ if 'autoscale' in args_subset:
2444
+ args.autoscale = bool(args_subset['autoscale'])
2445
+ if 'norm' in args_subset:
2446
+ args.norm = bool(args_subset['norm'])
2447
+ except Exception:
2448
+ pass
2449
+ try:
2450
+ plt.ion()
2451
+ except Exception:
2452
+ pass
2453
+ try:
2454
+ plt.show(block=False)
2455
+ except Exception:
2456
+ pass
2457
+
2458
+ # CRITICAL: Disable automatic layout adjustments to ensure parameter independence
2459
+ # This prevents matplotlib from moving axes when labels are changed
2460
+ try:
2461
+ fig.set_layout_engine('none')
2462
+ except AttributeError:
2463
+ # Older matplotlib versions - disable tight_layout
2464
+ try:
2465
+ fig.set_tight_layout(False)
2466
+ except Exception:
2467
+ pass
2468
+
2469
+ interactive_menu(fig, ax, y_data_list, x_data_list, labels_list,
2470
+ orig_y, label_text_objects, delta, x_label, args,
2471
+ x_full_list, raw_y_full_list, offsets_list,
2472
+ use_Q, use_r, use_E, use_k, use_rft)
2473
+ plt.show()
2474
+ exit()
2475
+
2476
+ # ---------------- Handle conversion ----------------
2477
+ if args.convert:
2478
+ if args.wl is None:
2479
+ print("Error: --wl is required for --convert")
2480
+
2481
+ exit(1)
2482
+ convert_to_qye(args.convert, args.wl)
2483
+ exit()
2484
+
2485
+ # ---------------- Plotting ----------------
2486
+ offset = 0.0
2487
+ direction = -1 if args.stack else 1 # stack downward
2488
+ if args.interactive:
2489
+ plt.ion()
2490
+ fig, ax = plt.subplots(figsize=(8, 6))
2491
+
2492
+ y_data_list = []
2493
+ x_data_list = []
2494
+ labels_list = []
2495
+ orig_y = []
2496
+ label_text_objects = []
2497
+ # New lists to preserve full data & offsets
2498
+ x_full_list = []
2499
+ raw_y_full_list = []
2500
+ offsets_list = []
2501
+
2502
+ # ---------------- Determine X-axis type ----------------
2503
+ def _ext_token(path):
2504
+ return os.path.splitext(path)[1].lower() # includes leading dot
2505
+
2506
+ # Check for CSV/MPT files with --xaxis time
2507
+ any_csv = any(f.lower().endswith((".csv", ".mpt")) for f in args.files)
2508
+ use_time_mode = any_csv and args.xaxis and args.xaxis.lower() == "time"
2509
+
2510
+ if use_time_mode:
2511
+ # Special mode: plot time (h) vs voltage (V) for electrochemistry CSV/MPT files
2512
+ axis_mode = "time"
2513
+ else:
2514
+ # Regular XRD/PDF/XAS mode - proceed with normal detection
2515
+ any_qye = any(f.lower().endswith(".qye") for f in args.files)
2516
+ any_gr = any(f.lower().endswith(".gr") for f in args.files)
2517
+ any_nor = any(f.lower().endswith(".nor") for f in args.files)
2518
+ any_chik = any("chik" in _ext_token(f) for f in args.files)
2519
+ any_chir = any("chir" in _ext_token(f) for f in args.files)
2520
+ any_txt = any(f.lower().endswith(".txt") for f in args.files)
2521
+ any_cif = any(f.lower().endswith(".cif") for f in args.files)
2522
+ non_cif_count = sum(0 if f.lower().endswith('.cif') else 1 for f in args.files)
2523
+ cif_only = any_cif and non_cif_count == 0
2524
+ any_lambda = any(":" in f for f in args.files) or args.wl is not None
2525
+
2526
+ # Incompatibilities (no mixing of fundamentally different axis domains)
2527
+ if sum(bool(x) for x in (any_gr, any_nor, any_chik, any_chir, (any_qye or any_lambda or any_cif))) > 1:
2528
+ raise ValueError("Cannot mix .gr (r), .nor (energy), .chik (k), .chir (FT-EXAFS R), and Q/2θ/CIF data together. Split runs.")
2529
+
2530
+ # Automatic axis selection based on file extensions
2531
+ if any_qye:
2532
+ axis_mode = "Q"
2533
+ elif any_gr:
2534
+ axis_mode = "r"
2535
+ elif any_nor:
2536
+ axis_mode = "energy"
2537
+ elif any_chik:
2538
+ axis_mode = "k"
2539
+ elif any_chir:
2540
+ axis_mode = "rft"
2541
+ elif any_txt:
2542
+ # .txt is generic, require --xaxis
2543
+ if args.xaxis:
2544
+ axis_mode = args.xaxis
2545
+ else:
2546
+ raise ValueError("Unknown file type. Use: batplot file.txt --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2547
+ elif any_lambda or any_cif:
2548
+ if args.xaxis and args.xaxis.lower() in ("2theta","two_theta","tth"):
2549
+ axis_mode = "2theta"
2550
+ else:
2551
+ # If wavelength is provided, user wants to convert to Q
2552
+ # CIF files are in Q space
2553
+ axis_mode = "Q"
2554
+ elif args.xaxis:
2555
+ axis_mode = args.xaxis
2556
+ else:
2557
+ raise ValueError("Unknown file type. Use: batplot file.csv --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2558
+
2559
+ use_Q = axis_mode == "Q"
2560
+ use_2th = axis_mode == "2theta"
2561
+ use_r = axis_mode == "r"
2562
+ use_E = axis_mode == "energy"
2563
+ use_k = axis_mode == "k" # NEW
2564
+ use_rft = axis_mode == "rft" # NEW
2565
+ use_time = axis_mode == "time" # NEW: electrochemistry time mode
2566
+
2567
+ # Initialize wavelength_file from args.wl (may be overridden per-file later)
2568
+ wavelength_file = getattr(args, 'wl', None)
2569
+
2570
+ # Validate: if using 2theta mode with CIF files, wavelength is required
2571
+ if use_2th and any_cif and not wavelength_file:
2572
+ raise ValueError(
2573
+ "Cannot display CIF files in 2θ mode without wavelength.\n"
2574
+ "Please provide wavelength using:\n"
2575
+ " --wl <wavelength_in_angstrom>\n"
2576
+ " or include wavelength in filename (e.g., data_wl1.5406.xy)\n"
2577
+ " or use Q mode (remove --xaxis 2theta)"
2578
+ )
2579
+
2580
+ # ---------------- Read and plot files ----------------
2581
+ # Helper to extract discrete peak positions from a simulated CIF pattern by local maxima picking
2582
+ def _extract_peak_positions(Q_array, I_array, min_rel_height=0.05):
2583
+ if Q_array.size == 0 or I_array.size == 0:
2584
+ return []
2585
+ Imax = I_array.max() if I_array.size else 0
2586
+ if Imax <= 0:
2587
+ return []
2588
+ thr = Imax * min_rel_height
2589
+ peaks = []
2590
+ for i in range(1, len(I_array)-1):
2591
+ if I_array[i] >= thr and I_array[i] >= I_array[i-1] and I_array[i] >= I_array[i+1]:
2592
+ # simple peak refine by local quadratic (optional)
2593
+ y1,y2,y3 = I_array[i-1], I_array[i], I_array[i+1]
2594
+ x1,x2,x3 = Q_array[i-1], Q_array[i], Q_array[i+1]
2595
+ denom = (y1 - 2*y2 + y3)
2596
+ if abs(denom) > 1e-12:
2597
+ dx = 0.5*(y1 - y3)/denom
2598
+ if -0.6 < dx < 0.6:
2599
+ xc = x2 + dx*(x3 - x1)/2.0
2600
+ if Q_array[0] <= xc <= Q_array[-1]:
2601
+ peaks.append(xc)
2602
+ continue
2603
+ peaks.append(Q_array[i])
2604
+ return peaks
2605
+
2606
+ # Will accumulate CIF tick series to render after main curves
2607
+ cif_tick_series = [] # list of (label, filename, peak_positions_Q, wavelength_or_None, qmax_simulated, color)
2608
+ cif_hkl_map = {} # filename -> list of (Q,h,k,l)
2609
+ cif_hkl_label_map = {} # filename -> dict of Q -> label string
2610
+ cif_numbering_enabled = True # show numbering for CIF tick sets (mixed mode only)
2611
+ cif_extend_suspended = False # guard flag to prevent auto extension during certain operations
2612
+ QUIET_CIF_EXTEND = True # suppress extension debug output
2613
+
2614
+ # Cached wavelength for CIF tick conversion (prevents interactive blocking prompts)
2615
+ cif_cached_wavelength = None
2616
+ show_cif_hkl = False
2617
+ show_cif_titles = True # show CIF filename labels by default
2618
+
2619
+ # Store wavelength info per file for crosshair display
2620
+ file_wavelength_info = [] # List of dicts: {'original_wl': float or None, 'conversion_wl': float or None}
2621
+
2622
+ # Separate style files from data files
2623
+ data_files = []
2624
+ style_file_path = None
2625
+ for f in args.files:
2626
+ ext = os.path.splitext(f)[1].lower()
2627
+ if ext in ('.bps', '.bpsg', '.bpcfg'):
2628
+ if style_file_path is None:
2629
+ style_file_path = f
2630
+ else:
2631
+ print(f"Warning: Multiple style files provided, using first: {style_file_path}")
2632
+ else:
2633
+ data_files.append(f)
2634
+
2635
+ # If no data files remain, exit
2636
+ if not data_files:
2637
+ print("No data files found (only style files provided).")
2638
+ exit(1)
2639
+
2640
+ # Load style file if provided
2641
+ style_cfg = None
2642
+ if style_file_path:
2643
+ if not os.path.isfile(style_file_path):
2644
+ print(f"Warning: Style file not found: {style_file_path}")
2645
+ else:
2646
+ try:
2647
+ with open(style_file_path, 'r', encoding='utf-8') as f:
2648
+ style_cfg = json.load(f)
2649
+ print(f"Using style file: {os.path.basename(style_file_path)}")
2650
+ except Exception as e:
2651
+ print(f"Warning: Could not load style file {style_file_path}: {e}")
2652
+
2653
+ # Use data_files instead of args.files for processing
2654
+ for idx_file, file_entry in enumerate(data_files):
2655
+ parts = file_entry.split(":")
2656
+ fname = parts[0]
2657
+ # Parse wavelength parameters: file:wl1 or file:wl1:wl2 or file.cif:wl
2658
+ wavelength_file = None
2659
+ original_wavelength = None # First wavelength (for Q conversion)
2660
+ conversion_wavelength = None # Second wavelength (for 2theta conversion back)
2661
+ if len(parts) == 2:
2662
+ # Single wavelength: file:wl or file.cif:wl
2663
+ try:
2664
+ wavelength_file = float(parts[1])
2665
+ original_wavelength = wavelength_file
2666
+ except ValueError:
2667
+ pass
2668
+ elif len(parts) == 3:
2669
+ # Dual wavelength: file:wl1:wl2
2670
+ try:
2671
+ original_wavelength = float(parts[1])
2672
+ conversion_wavelength = float(parts[2])
2673
+ wavelength_file = conversion_wavelength # Use second for final conversion
2674
+ except ValueError:
2675
+ pass
2676
+ if wavelength_file is None:
2677
+ wavelength_file = args.wl
2678
+ if not os.path.isfile(fname):
2679
+ print(f"File not found: {fname}")
2680
+ continue
2681
+ file_ext = os.path.splitext(fname)[1].lower()
2682
+ is_chik = "chik" in file_ext
2683
+ is_chir = "chir" in file_ext
2684
+ is_cif = file_ext == '.cif'
2685
+ label = os.path.basename(fname)
2686
+ if wavelength_file and not use_r and not use_E and file_ext not in (".gr", ".nor", ".cif"):
2687
+ if conversion_wavelength is not None:
2688
+ label += f" (λ₁={original_wavelength:.5f}→λ₂={conversion_wavelength:.5f} Å)"
2689
+ else:
2690
+ label += f" (λ={wavelength_file:.5f} Å)"
2691
+ # Store wavelength info for this file
2692
+ file_wavelength_info.append({
2693
+ 'original_wl': original_wavelength,
2694
+ 'conversion_wl': conversion_wavelength,
2695
+ 'final_wl': wavelength_file
2696
+ })
2697
+
2698
+ # ---- Read data (time mode for CSV/MPT or regular mode) ----
2699
+ if use_time and file_ext in ('.csv', '.mpt'):
2700
+ # Time mode: read time (h) vs voltage (V) for electrochemistry files
2701
+ try:
2702
+ if file_ext == '.csv':
2703
+ x, y = read_csv_time_voltage(fname)
2704
+ elif file_ext == '.mpt':
2705
+ x, y = read_mpt_time_voltage(fname)
2706
+ e = None
2707
+ except Exception as e_read:
2708
+ print(f"Error reading {fname} in time mode: {e_read}")
2709
+ continue
2710
+ elif is_cif:
2711
+ try:
2712
+ # Simulate pattern directly in Q space regardless of current axis_mode
2713
+ Q_sim, I_sim = simulate_cif_pattern_Q(fname)
2714
+ x = Q_sim
2715
+ y = I_sim
2716
+ e = None
2717
+ # Force axis mode if needed
2718
+ if not (use_Q or use_2th):
2719
+ use_Q = True
2720
+ # Reflection list and per-Q hkl labels (no wavelength cutoff in pure Q domain)
2721
+ qmax_sim = float(Q_sim[-1]) if len(Q_sim) else 0.0
2722
+ refl = cif_reflection_positions(fname, Qmax=qmax_sim, wavelength=None)
2723
+ hkl_list = list_reflections_with_hkl(fname, Qmax=qmax_sim, wavelength=None)
2724
+ cif_hkl_label_map[fname] = build_hkl_label_map_from_list(hkl_list)
2725
+ # Store wavelength for CIF ticks: use provided wavelength if in 2theta mode
2726
+ cif_wl = wavelength_file if use_2th and wavelength_file else None
2727
+ # default tick color black
2728
+ cif_tick_series.append((label, fname, refl, cif_wl, qmax_sim, 'k'))
2729
+ # If CIF mixed with other data types, do NOT plot intensity curve (ticks only)
2730
+ if not cif_only:
2731
+ continue # skip rest of loop so curve isn't added
2732
+ except Exception as e_read:
2733
+ print(f"Error simulating CIF {fname}: {e_read}")
2734
+ continue
2735
+ elif file_ext == ".gr":
2736
+ try:
2737
+ x, y = read_gr_file(fname); e = None
2738
+ except Exception as e_read:
2739
+ print(f"Error reading {fname}: {e_read}"); continue
2740
+ elif file_ext in [".nor", ".xy", ".xye", ".qye", ".dat", ".csv"] or is_chik or is_chir:
2741
+ try:
2742
+ data = robust_loadtxt_skipheader(fname)
2743
+ except Exception as e_read:
2744
+ print(f"Error reading {fname}: {e_read}"); continue
2745
+ if data.ndim == 1: data = data.reshape(1, -1)
2746
+ if data.shape[1] < 2:
2747
+ print(f"Invalid data format in {fname}"); continue
2748
+ # Handle --readcol flag to select specific columns
2749
+ # Check for extension-specific readcol first, then fall back to general --readcol
2750
+ readcol_spec = None
2751
+ if hasattr(args, 'readcol_by_ext') and file_ext in args.readcol_by_ext:
2752
+ readcol_spec = args.readcol_by_ext[file_ext]
2753
+ elif args.readcol:
2754
+ readcol_spec = args.readcol
2755
+
2756
+ if readcol_spec:
2757
+ x_col, y_col = readcol_spec
2758
+ # Convert from 1-indexed to 0-indexed
2759
+ x_col_idx = x_col - 1
2760
+ y_col_idx = y_col - 1
2761
+ if x_col_idx < 0 or x_col_idx >= data.shape[1]:
2762
+ print(f"Error: X column {x_col} out of range in {fname} (has {data.shape[1]} columns)")
2763
+ continue
2764
+ if y_col_idx < 0 or y_col_idx >= data.shape[1]:
2765
+ print(f"Error: Y column {y_col} out of range in {fname} (has {data.shape[1]} columns)")
2766
+ continue
2767
+ x, y = data[:, x_col_idx], data[:, y_col_idx]
2768
+ e = None # Error bars not supported with custom column selection
2769
+ else:
2770
+ x, y = data[:, 0], data[:, 1]
2771
+ e = data[:, 2] if data.shape[1] >= 3 else None
2772
+ # For .csv, .dat, .xy, .xye, .qye, .nor, .chik, .chir, this robustly skips headers
2773
+ elif args.fullprof and file_ext == ".dat":
2774
+ try:
2775
+ y_plot, n_rows = read_fullprof_rowwise(fname)
2776
+ xstart, xend, xstep = args.fullprof[0], args.fullprof[1], args.fullprof[2]
2777
+ x_plot = np.linspace(xstart, xend, len(y_plot))
2778
+ wavelength = args.fullprof[3] if len(args.fullprof)>=4 else wavelength_file
2779
+ if use_Q and wavelength:
2780
+ theta_rad = np.radians(x_plot / 2)
2781
+ x_plot = 4*np.pi*np.sin(theta_rad)/wavelength
2782
+ e_plot = None
2783
+ except Exception as e:
2784
+ print(f"Error reading FullProf-style {fname}: {e}")
2785
+ continue
2786
+ else:
2787
+ # Unknown extension: attempt to read as 2-column (x, y) data
2788
+ try:
2789
+ data = robust_loadtxt_skipheader(fname)
2790
+ except Exception as e_read:
2791
+ print(f"Error reading {fname} (unknown extension '{file_ext}'): {e_read}")
2792
+ continue
2793
+ if data.ndim == 1: data = data.reshape(1, -1)
2794
+ if data.shape[1] < 2:
2795
+ print(f"Invalid data format in {fname}: expected at least 2 columns, got {data.shape[1]}")
2796
+ continue
2797
+ # Handle --readcol flag to select specific columns
2798
+ # Check for extension-specific readcol first, then fall back to general --readcol
2799
+ readcol_spec = None
2800
+ if hasattr(args, 'readcol_by_ext') and file_ext in args.readcol_by_ext:
2801
+ readcol_spec = args.readcol_by_ext[file_ext]
2802
+ elif args.readcol:
2803
+ readcol_spec = args.readcol
2804
+
2805
+ if readcol_spec:
2806
+ x_col, y_col = readcol_spec
2807
+ # Convert from 1-indexed to 0-indexed
2808
+ x_col_idx = x_col - 1
2809
+ y_col_idx = y_col - 1
2810
+ if x_col_idx < 0 or x_col_idx >= data.shape[1]:
2811
+ print(f"Error: X column {x_col} out of range in {fname} (has {data.shape[1]} columns)")
2812
+ continue
2813
+ if y_col_idx < 0 or y_col_idx >= data.shape[1]:
2814
+ print(f"Error: Y column {y_col} out of range in {fname} (has {data.shape[1]} columns)")
2815
+ continue
2816
+ x, y = data[:, x_col_idx], data[:, y_col_idx]
2817
+ e = None # Error bars not supported with custom column selection
2818
+ else:
2819
+ x, y = data[:, 0], data[:, 1]
2820
+ e = data[:, 2] if data.shape[1] >= 3 else None
2821
+ # Warn once per unknown extension type
2822
+ if not hasattr(args, '_warned_extensions'):
2823
+ args._warned_extensions = set()
2824
+ if file_ext and file_ext not in args._warned_extensions:
2825
+ args._warned_extensions.add(file_ext)
2826
+ print(f"Note: Reading '{file_ext}' file as 2-column (x, y) data. Use --xaxis to specify x-axis type if needed.")
2827
+
2828
+ # ---- X-axis conversion logic updated (no conversion for energy or time) ----
2829
+ if use_time:
2830
+ # Time mode: data already in hours, no conversion needed
2831
+ x_plot = x
2832
+ elif use_2th and original_wavelength is not None and conversion_wavelength is not None:
2833
+ # Dual wavelength conversion: 2theta -> Q (wl1) -> 2theta (wl2)
2834
+ # Step 1: Convert original 2theta to Q using first wavelength
2835
+ theta_rad = np.radians(x / 2.0)
2836
+ Q = 4 * np.pi * np.sin(theta_rad) / original_wavelength
2837
+ # Step 2: Convert Q back to 2theta using second wavelength
2838
+ # Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
2839
+ sin_theta = Q * conversion_wavelength / (4 * np.pi)
2840
+ # Clamp to valid range [-1, 1]
2841
+ sin_theta = np.clip(sin_theta, -1.0, 1.0)
2842
+ theta_new_rad = np.arcsin(sin_theta)
2843
+ x_plot = np.degrees(2 * theta_new_rad)
2844
+ elif use_2th and file_ext == ".qye" and wavelength_file:
2845
+ # Convert Q to 2theta for .qye files when wavelength is provided
2846
+ # Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
2847
+ sin_theta = x * wavelength_file / (4 * np.pi)
2848
+ # Clamp to valid range [-1, 1]
2849
+ sin_theta = np.clip(sin_theta, -1.0, 1.0)
2850
+ theta_rad = np.arcsin(sin_theta)
2851
+ x_plot = np.degrees(2 * theta_rad)
2852
+ elif use_Q and file_ext not in (".qye", ".gr", ".nor"):
2853
+ if original_wavelength is not None:
2854
+ # Use first wavelength for Q conversion
2855
+ theta_rad = np.radians(x/2)
2856
+ x_plot = 4*np.pi*np.sin(theta_rad)/original_wavelength
2857
+ elif wavelength_file:
2858
+ theta_rad = np.radians(x/2)
2859
+ x_plot = 4*np.pi*np.sin(theta_rad)/wavelength_file
2860
+ else:
2861
+ x_plot = x
2862
+ else:
2863
+ # r, energy, k, rft, or already Q: direct
2864
+ x_plot = x
2865
+
2866
+ # ---- Store full (converted) arrays BEFORE cropping ----
2867
+ x_full = x_plot.copy()
2868
+ y_full_raw = y.copy()
2869
+ raw_y_full_list.append(y_full_raw)
2870
+ x_full_list.append(x_full)
2871
+
2872
+ # ---- Apply xrange (for initial display only; full data kept above) ----
2873
+ y_plot = y_full_raw
2874
+ e_plot = e
2875
+ if args.xrange:
2876
+ mask = (x_full>=args.xrange[0]) & (x_full<=args.xrange[1])
2877
+ ax.set_xlim(args.xrange[0], args.xrange[1])
2878
+ x_plot = x_full[mask]
2879
+ y_plot = y_full_raw[mask]
2880
+ if e_plot is not None:
2881
+ e_plot = e_plot[mask]
2882
+ else:
2883
+ x_plot = x_full
2884
+
2885
+ # ---- Apply EXAFS k-weighting transformation if requested ----
2886
+ if getattr(args, 'k3chik', False):
2887
+ # Multiply y by x³ for EXAFS k³χ(k) plots
2888
+ y_plot = y_plot * (x_plot ** 3)
2889
+ y_full_raw = y_full_raw * (x_full ** 3)
2890
+ raw_y_full_list[-1] = y_full_raw
2891
+ elif getattr(args, 'k2chik', False):
2892
+ # Multiply y by x² for EXAFS k²χ(k) plots
2893
+ y_plot = y_plot * (x_plot ** 2)
2894
+ y_full_raw = y_full_raw * (x_full ** 2)
2895
+ raw_y_full_list[-1] = y_full_raw
2896
+ elif getattr(args, 'kchik', False):
2897
+ # Multiply y by x for EXAFS kχ(k) plots
2898
+ y_plot = y_plot * x_plot
2899
+ y_full_raw = y_full_raw * x_full
2900
+ raw_y_full_list[-1] = y_full_raw
2901
+ # elif getattr(args, 'chik', False): no multiplication needed, just label change
2902
+
2903
+ # ---- Normalize (display subset) ----
2904
+ # Auto-normalize for --stack mode, or explicit --norm flag
2905
+ should_normalize = args.stack or getattr(args, 'norm', False)
2906
+ if should_normalize:
2907
+ # Min–max normalization to 0..1 within the currently displayed (cropped) segment
2908
+ if y_plot.size:
2909
+ y_min = float(y_plot.min())
2910
+ y_max = float(y_plot.max())
2911
+ span = y_max - y_min
2912
+ if span > 0:
2913
+ y_norm = (y_plot - y_min) / span
2914
+ else:
2915
+ # Flat line -> all zeros
2916
+ y_norm = np.zeros_like(y_plot)
2917
+ else:
2918
+ y_norm = y_plot
2919
+ else:
2920
+ y_norm = y_plot
2921
+
2922
+ # ---- Apply offset (waterfall vs stack) ----
2923
+ if args.stack:
2924
+ y_plot_offset = y_norm + offset
2925
+ y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
2926
+ gap = y_range + (args.delta * (y_range if args.autoscale else 1.0))
2927
+ offsets_list.append(offset)
2928
+ offset -= gap
2929
+ else:
2930
+ increment = (y_norm.max() - y_norm.min()) * args.delta if (args.autoscale and y_norm.size) else args.delta
2931
+ y_plot_offset = y_norm + offset
2932
+ offsets_list.append(offset)
2933
+ offset += increment
2934
+
2935
+ # ---- Plot curve ----
2936
+ # Swap x and y if --ro flag is set
2937
+ if getattr(args, 'ro', False):
2938
+ ax.plot(y_plot_offset, x_plot, "-", lw=1, alpha=0.8)
2939
+ y_data_list.append(x_plot.copy())
2940
+ x_data_list.append(y_plot_offset)
2941
+ else:
2942
+
2943
+ ax.plot(x_plot, y_plot_offset, "-", lw=1, alpha=0.8)
2944
+ y_data_list.append(y_plot_offset.copy())
2945
+ x_data_list.append(x_plot)
2946
+ labels_list.append(label)
2947
+ # Store current normalized (subset) (used by rearrange logic)
2948
+ orig_y.append(y_norm.copy())
2949
+
2950
+ # ---------------- Force axis to fit all data before labels ----------------
2951
+ ax.relim()
2952
+ ax.autoscale_view()
2953
+ fig.canvas.draw()
2954
+
2955
+ # Store the x/y limits that were used for data normalization (.bpsg save/restore)
2956
+ ax._norm_xlim = tuple(ax.get_xlim())
2957
+ ax._norm_ylim = tuple(ax.get_ylim())
2958
+
2959
+ # Define a sample_tick safely (may be None if no labels yet)
2960
+ sample_tick = None
2961
+ xt_lbls = ax.get_xticklabels()
2962
+ if xt_lbls:
2963
+ sample_tick = xt_lbls[0]
2964
+
2965
+ else:
2966
+ yt_lbls = ax.get_yticklabels()
2967
+ if yt_lbls:
2968
+ sample_tick = yt_lbls[0]
2969
+
2970
+ # ---------------- Initial label creation (REPLACED BLOCK) ----------------
2971
+ # Remove the old simple per-curve placement loop and use:
2972
+ label_text_objects = []
2973
+ tick_fs = sample_tick.get_fontsize() if sample_tick else plt.rcParams.get('font.size', 16)
2974
+ # get_fontname() may not exist on some backends; use family from rcParams if missing
2975
+ try:
2976
+ tick_fn = sample_tick.get_fontname() if sample_tick else plt.rcParams.get('font.sans-serif', ['DejaVu Sans'])[0]
2977
+ except Exception:
2978
+ tick_fn = plt.rcParams.get('font.sans-serif', ['DejaVu Sans'])[0]
2979
+
2980
+ if args.stack:
2981
+ x_max = ax.get_xlim()[1]
2982
+ for i, y_plot_offset in enumerate(y_data_list):
2983
+ y_max_curve = y_plot_offset.max() if len(y_plot_offset) else ax.get_ylim()[1]
2984
+ txt = ax.text(x_max, y_max_curve,
2985
+ f"{i+1}: {labels_list[i]}",
2986
+ va='top', ha='right',
2987
+ fontsize=tick_fs, fontname=tick_fn,
2988
+ transform=ax.transData)
2989
+ label_text_objects.append(txt)
2990
+ else:
2991
+ n = len(y_data_list)
2992
+ top_pad = 0.02
2993
+ start_y = 0.98
2994
+ spacing = min(0.08, max(0.025, 0.90 / max(n, 1)))
2995
+ for i in range(n):
2996
+ y_pos = start_y - i * spacing
2997
+ if y_pos < 0.02:
2998
+ y_pos = 0.02
2999
+ txt = ax.text(1.0, y_pos,
3000
+ f"{i+1}: {labels_list[i]}",
3001
+ va='top', ha='right',
3002
+ fontsize=tick_fs, fontname=tick_fn,
3003
+ transform=ax.transAxes)
3004
+ label_text_objects.append(txt)
3005
+
3006
+ # Ensure consistent initial placement (especially for stacked mode)
3007
+ update_labels(ax, y_data_list, label_text_objects, args.stack, False)
3008
+
3009
+ # Initialize curve names visibility (default to visible)
3010
+ fig._curve_names_visible = True
3011
+ # Initialize stack label position (default to top/max)
3012
+ fig._stack_label_at_bottom = False
3013
+ fig._label_anchor_left = False
3014
+
3015
+ # ---------------- CIF tick overlay (after labels placed) ----------------
3016
+ def _ensure_wavelength_for_2theta():
3017
+ """Ensure wavelength assigned to all CIF tick sets without prompting.
3018
+
3019
+ Order of preference:
3020
+ 1. Existing wavelength already stored in any series.
3021
+ 2. args.wl if provided by user.
3022
+ 3. Previously cached value (cif_cached_wavelength).
3023
+ 4. Default 1.5406 Å.
3024
+ """
3025
+ global cif_cached_wavelength
3026
+ if not cif_tick_series:
3027
+ return None
3028
+ # If any entry already has wavelength, use and cache it
3029
+ for _lab,_fname,_peaks,_wl,_qmax,_color in cif_tick_series:
3030
+ if _wl is not None:
3031
+ cif_cached_wavelength = _wl
3032
+ return _wl
3033
+ wl = getattr(args, 'wl', None)
3034
+ if wl is None:
3035
+ wl = cif_cached_wavelength if cif_cached_wavelength is not None else 1.5406
3036
+ cif_cached_wavelength = wl
3037
+ for i,(lab, fname, peaksQ, w0, qmax_sim, color) in enumerate(cif_tick_series):
3038
+ cif_tick_series[i] = (lab, fname, peaksQ, wl, qmax_sim, color)
3039
+ return wl
3040
+
3041
+ def _Q_to_2theta(peaksQ, wl):
3042
+ out = []
3043
+ if wl is None:
3044
+ return out
3045
+ for q in peaksQ:
3046
+ s = q*wl/(4*np.pi)
3047
+ if 0 <= s < 1:
3048
+ out.append(np.degrees(2*np.arcsin(s)))
3049
+ return out
3050
+
3051
+ def extend_cif_tick_series(xmax_domain):
3052
+ """Extend CIF peak list if x-range upper bound increases beyond simulated Qmax.
3053
+ xmax_domain: upper x limit in current axis units (Q or 2θ).
3054
+ """
3055
+ if globals().get('cif_extend_suspended', False):
3056
+ return
3057
+ if not cif_tick_series:
3058
+ return
3059
+ # Determine target Q for extension depending on axis
3060
+ wl_any = None
3061
+ if use_2th:
3062
+ # Ensure wavelength known
3063
+ for _,_,_,wl_,_ in cif_tick_series:
3064
+ if wl_ is not None:
3065
+ wl_any = wl_
3066
+ break
3067
+ if wl_any is None:
3068
+ wl_any = _ensure_wavelength_for_2theta()
3069
+ updated = False
3070
+ for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
3071
+ if use_2th:
3072
+ wl_use = wl if wl is not None else wl_any
3073
+ theta_rad = np.radians(min(xmax_domain, 179.9)/2.0)
3074
+ Q_target = 4*np.pi*np.sin(theta_rad)/wl_use if wl_use else qmax_sim
3075
+ else:
3076
+ Q_target = xmax_domain
3077
+ if not QUIET_CIF_EXTEND:
3078
+ try:
3079
+ print(f"[CIF extend check] {lab}: current Qmax={qmax_sim:.3f}, target Q={Q_target:.3f}")
3080
+ except Exception:
3081
+ pass
3082
+ if Q_target > qmax_sim + 1e-6:
3083
+ new_Qmax = Q_target + 0.25
3084
+ try:
3085
+ # Only apply wavelength constraint for 2θ axis; in Q axis enumerate freely
3086
+ refl = cif_reflection_positions(fname, Qmax=new_Qmax, wavelength=(wl if (wl and use_2th) else None))
3087
+ cif_tick_series[i] = (lab, fname, refl, wl, float(new_Qmax), color)
3088
+ if not QUIET_CIF_EXTEND:
3089
+ print(f"Extended CIF ticks for {lab} to Qmax={new_Qmax:.2f} (count={len(refl)})")
3090
+ updated = True
3091
+ except Exception as e:
3092
+ print(f"Warning: could not extend CIF peaks for {lab}: {e}")
3093
+ if updated:
3094
+ # After update, redraw ticks
3095
+ draw_cif_ticks()
3096
+
3097
+ def draw_cif_ticks():
3098
+ if not cif_tick_series:
3099
+ return
3100
+ # Preserve both x and y-axis limits to prevent movement when toggling
3101
+ prev_xlim = ax.get_xlim()
3102
+ prev_ylim = ax.get_ylim()
3103
+ # Use preserved y-axis limits for calculations to prevent incremental movement
3104
+ orig_ylim = prev_ylim
3105
+ orig_yr = orig_ylim[1] - orig_ylim[0]
3106
+ if orig_yr <= 0: orig_yr = 1.0
3107
+ # Check visibility flag first to decide if we need to adjust y-axis
3108
+ show_titles = show_cif_titles # Use closure variable
3109
+ try:
3110
+ # Check __main__ module first (for backward compatibility)
3111
+ _bp_module = sys.modules.get('__main__')
3112
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_titles'):
3113
+ show_titles = bool(getattr(_bp_module, 'show_cif_titles', True))
3114
+ # Also check if stored on figure/axes (from interactive menu)
3115
+ if hasattr(fig, '_bp_show_cif_titles'):
3116
+ show_titles = bool(getattr(fig, '_bp_show_cif_titles', True))
3117
+ except Exception:
3118
+ pass
3119
+ # Calculate base and spacing based on original y-axis limits
3120
+ if args.stack or len(y_data_list) > 1:
3121
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
3122
+ base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
3123
+ else:
3124
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3125
+ base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
3126
+ # Only adjust y-axis limits if titles are visible
3127
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
3128
+ cur_ylim = ax.get_ylim()
3129
+ yr = cur_ylim[1] - cur_ylim[0]
3130
+ if yr <= 0: yr = 1.0
3131
+ if show_titles and needed_min < orig_ylim[0]:
3132
+ # Expand y-axis only if needed, using original limits as reference
3133
+ ax.set_ylim(needed_min, orig_ylim[1])
3134
+ cur_ylim = ax.get_ylim()
3135
+ yr = cur_ylim[1] - cur_ylim[0]
3136
+ if yr <= 0: yr = 1.0
3137
+ # Recalculate base with new limits if we expanded
3138
+ if args.stack or len(y_data_list) > 1:
3139
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
3140
+ base = global_min - 0.08*yr; spacing = 0.05*yr
3141
+ else:
3142
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3143
+ base = global_min - 0.06*yr; spacing = 0.04*yr
3144
+ # Clear previous
3145
+ for art in getattr(ax, '_cif_tick_art', []):
3146
+ try: art.remove()
3147
+ except Exception: pass
3148
+ new_art = []
3149
+ mixed_mode = (not cif_only) # cif_only variable defined earlier in script context
3150
+ show_hkl = globals().get('show_cif_hkl', False)
3151
+ for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
3152
+ y_line = base - i*spacing
3153
+ if use_2th:
3154
+ if wl is None: wl = _ensure_wavelength_for_2theta()
3155
+ domain_peaks = _Q_to_2theta(peaksQ, wl)
3156
+ else:
3157
+ domain_peaks = peaksQ
3158
+ # --- NEW: restrict to current visible x-range for performance ---
3159
+ xlow, xhigh = ax.get_xlim()
3160
+ if domain_peaks:
3161
+ # domain_peaks may be numpy array or list; create filtered list
3162
+ domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
3163
+ if not domain_peaks:
3164
+ # No peaks in current window; still write label row and continue (if titles are visible)
3165
+ if show_titles:
3166
+ # Removed numbering; keep space padding
3167
+ label_text = f" {lab}"
3168
+ txt = ax.text(prev_xlim[0], y_line + 0.005*yr, label_text,
3169
+ ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
3170
+ new_art.append(txt)
3171
+ continue
3172
+ # Build map for quick hkl lookup by Q
3173
+ hkl_entries = cif_hkl_map.get(fname, [])
3174
+ # dictionary keyed by Q value
3175
+ hkl_by_q = {}
3176
+ for qval,h,k,l in hkl_entries:
3177
+ hkl_by_q.setdefault(qval, []).append((h,k,l))
3178
+ label_map = cif_hkl_label_map.get(fname, {})
3179
+ # --- Optimized tick & hkl label drawing ---
3180
+ if show_hkl and peaksQ and label_map:
3181
+ # Guard against pathological large peak lists (can freeze UI)
3182
+ if len(peaksQ) > 4000 or len(domain_peaks) > 4000:
3183
+ print(f"[hkl] Too many peaks in {lab} (>{len(peaksQ)}) – skipping hkl labels. Press 'z' again to toggle off.")
3184
+ # still draw ticks below without labels
3185
+ effective_show_hkl = False
3186
+ else:
3187
+ effective_show_hkl = True
3188
+ else:
3189
+ effective_show_hkl = False
3190
+
3191
+ # Precompute rounding function once
3192
+ if effective_show_hkl:
3193
+ # For 2θ axis we convert back to Q then round; otherwise Q directly
3194
+ for p in domain_peaks:
3195
+ ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
3196
+ new_art.append(ln)
3197
+ if use_2th and wl:
3198
+ theta = np.radians(p/2.0)
3199
+ Qp = 4*np.pi*np.sin(theta)/wl
3200
+ else:
3201
+ Qp = p
3202
+ lbl = label_map.get(round(Qp,6))
3203
+ if lbl:
3204
+ t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
3205
+ new_art.append(t_hkl)
3206
+ else:
3207
+ # Just draw ticks (no hkl labels)
3208
+ for p in domain_peaks:
3209
+ ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
3210
+ new_art.append(ln)
3211
+ # Removed numbering; keep space padding (placed per CIF row)
3212
+ # Only add title label if show_cif_titles is True
3213
+ if show_titles:
3214
+ label_text = f" {lab}"
3215
+ txt = ax.text(prev_xlim[0], y_line + 0.005*yr, label_text,
3216
+ ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
3217
+ new_art.append(txt)
3218
+ ax._cif_tick_art = new_art
3219
+ # Restore both x and y-axis limits to prevent movement
3220
+ ax.set_xlim(prev_xlim)
3221
+ # Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
3222
+ if not show_titles:
3223
+ # Titles hidden: always restore original limits
3224
+ ax.set_ylim(prev_ylim)
3225
+ elif needed_min >= prev_ylim[0]:
3226
+ # Titles shown but no expansion needed: restore original limits
3227
+ ax.set_ylim(prev_ylim)
3228
+ # Otherwise, keep the expanded limits (already set above)
3229
+ # Store simplified metadata for hover: list of dicts with 'x','y','label'
3230
+ hover_meta = []
3231
+ show_hkl = globals().get('show_cif_hkl', False)
3232
+ # Build mapping from Q to label text if available
3233
+ for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
3234
+ if use_2th and wl is None:
3235
+ wl = getattr(ax, '_cif_hover_wl', None)
3236
+ # Recreate domain peaks consistent with those drawn (limit to view)
3237
+ if use_2th:
3238
+ if wl is None: continue
3239
+ domain_peaks = _Q_to_2theta(peaksQ, wl)
3240
+ else:
3241
+ domain_peaks = peaksQ
3242
+ xlow, xhigh = ax.get_xlim()
3243
+ domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
3244
+ if not domain_peaks:
3245
+ continue
3246
+ # y baseline for this series (same logic as above)
3247
+ if args.stack or len(y_data_list) > 1:
3248
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else ax.get_ylim()[0]
3249
+ base = global_min - 0.08*yr; spacing = 0.05*yr
3250
+ else:
3251
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3252
+ base = global_min - 0.06*yr; spacing = 0.04*yr
3253
+ y_line = base - i*spacing
3254
+ label_map = cif_hkl_label_map.get(fname, {}) if show_hkl else {}
3255
+ for p in domain_peaks:
3256
+ if use_2th and wl:
3257
+ theta = np.radians(p/2.0); Qp = 4*np.pi*np.sin(theta)/wl
3258
+ else:
3259
+ Qp = p
3260
+ lbl = label_map.get(round(Qp,6), None)
3261
+ hover_meta.append({'x': p, 'y': y_line, 'hkl': lbl, 'series': lab})
3262
+ ax._cif_tick_hover_meta = hover_meta
3263
+ fig.canvas.draw_idle()
3264
+
3265
+ # Install hover handler once
3266
+ if not hasattr(ax, '_cif_hover_cid'):
3267
+ tooltip = ax.text(0,0,"", va='bottom', ha='left', fontsize=8,
3268
+ color='black', bbox=dict(boxstyle='round,pad=0.2', fc='1.0', ec='0.7', alpha=0.85),
3269
+ visible=False)
3270
+ ax._cif_hover_tooltip = tooltip
3271
+ def _on_move(event):
3272
+ if event.inaxes != ax:
3273
+ if tooltip.get_visible():
3274
+ tooltip.set_visible(False); fig.canvas.draw_idle()
3275
+ return
3276
+ meta = getattr(ax, '_cif_tick_hover_meta', None)
3277
+ if not meta:
3278
+ if tooltip.get_visible():
3279
+ tooltip.set_visible(False); fig.canvas.draw_idle()
3280
+ return
3281
+ x = event.xdata; y = event.ydata
3282
+ # Find nearest tick within pixel tolerance
3283
+ trans = ax.transData
3284
+ best = None; best_d2 = 25 # squared pixel distance threshold (5 px)
3285
+ for entry in meta:
3286
+ px, py = trans.transform((entry['x'], entry['y']))
3287
+ ex, ey = trans.transform((x, y))
3288
+ d2 = (px-ex)**2 + (py-ey)**2
3289
+ if d2 < best_d2:
3290
+ best_d2 = d2; best = entry
3291
+ if best is None:
3292
+ if tooltip.get_visible():
3293
+ tooltip.set_visible(False); fig.canvas.draw_idle()
3294
+ return
3295
+ # Compose text
3296
+ hkl_txt = best['hkl'] if best.get('hkl') else ''
3297
+ tip = f"{best['series']}\nQ={best['x']:.4f}" if use_Q else (f"{best['series']}\n2θ={best['x']:.4f}" if use_2th else f"{best['series']} {best['x']:.4f}")
3298
+ if hkl_txt:
3299
+ tip += f"\n{hkl_txt}"
3300
+ tooltip.set_text(tip)
3301
+ tooltip.set_position((best['x'], best['y'] + 0.025*yr))
3302
+ if not tooltip.get_visible():
3303
+ tooltip.set_visible(True)
3304
+ fig.canvas.draw_idle()
3305
+ cid = fig.canvas.mpl_connect('motion_notify_event', _on_move)
3306
+ ax._cif_hover_cid = cid
3307
+
3308
+ if cif_tick_series:
3309
+ # Auto-assign distinct colors if all are default 'k'
3310
+ if len(cif_tick_series) > 1:
3311
+ if all(c[-1] == 'k' for c in cif_tick_series):
3312
+ try:
3313
+ cmap_name = 'tab10' if len(cif_tick_series) <= 10 else 'hsv'
3314
+ cmap = plt.get_cmap(cmap_name)
3315
+ new_series = []
3316
+ for i,(lab,fname,peaksQ,wl,qmax_sim,col) in enumerate(cif_tick_series):
3317
+ color = cmap(i / max(1,(len(cif_tick_series)-1)))
3318
+ new_series.append((lab,fname,peaksQ,wl,qmax_sim,color))
3319
+ cif_tick_series[:] = new_series
3320
+ except Exception:
3321
+ pass
3322
+ if use_2th:
3323
+ _ensure_wavelength_for_2theta()
3324
+ draw_cif_ticks()
3325
+ # expose helpers for interactive updates
3326
+ ax._cif_extend_func = extend_cif_tick_series
3327
+ ax._cif_draw_func = draw_cif_ticks
3328
+
3329
+ # Handle EXAFS k-weighted χ(k) mode labels
3330
+ if getattr(args, 'k3chik', False):
3331
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
3332
+ y_label = r"k$^3$χ(k) ($\mathrm{\AA}^{-3}$)"
3333
+ elif getattr(args, 'k2chik', False):
3334
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
3335
+ y_label = r"k$^2$χ(k) ($\mathrm{\AA}^{-2}$)"
3336
+ elif getattr(args, 'kchik', False):
3337
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
3338
+ y_label = r"kχ(k) ($\mathrm{\AA}^{-1}$)"
3339
+ elif getattr(args, 'chik', False):
3340
+ x_label = r"k ($\mathrm{\AA}^{-1}$)"
3341
+ y_label = r"χ(k)"
3342
+ else:
3343
+ if use_E: x_label = "Energy (eV)"
3344
+ elif use_r: x_label = r"r (Å)"
3345
+ elif use_k: x_label = r"k ($\mathrm{\AA}^{-1}$)"
3346
+ elif use_rft: x_label = "Radial distance (Å)"
3347
+ elif use_Q: x_label = r"Q ($\mathrm{\AA}^{-1}$)"
3348
+ elif use_2th: x_label = r"$2\theta$ (deg)"
3349
+ elif use_time: x_label = "Time (h)"
3350
+ elif args.xaxis:
3351
+ x_label = str(args.xaxis)
3352
+ else:
3353
+ x_label = "X"
3354
+
3355
+ # Y-axis label: normalized if --stack or --norm, or voltage for time mode
3356
+ should_normalize = args.stack or getattr(args, 'norm', False)
3357
+ if use_time:
3358
+ y_label = "Voltage (V)"
3359
+ elif should_normalize:
3360
+ y_label = "Normalized intensity (a.u.)"
3361
+ else:
3362
+ y_label = "Intensity"
3363
+
3364
+ # Swap axis labels if --ro flag is set
3365
+ if getattr(args, 'ro', False):
3366
+ ax.set_xlabel(y_label, fontsize=16)
3367
+ ax.set_ylabel(x_label, fontsize=16)
3368
+ else:
3369
+
3370
+ ax.set_xlabel(x_label, fontsize=16)
3371
+ ax.set_ylabel(y_label, fontsize=16)
3372
+
3373
+ # Store originals for axis-title toggle restoration (t menu bn/ln)
3374
+ try:
3375
+ ax._stored_xlabel = ax.get_xlabel()
3376
+ ax._stored_ylabel = ax.get_ylabel()
3377
+ except Exception:
3378
+ pass
3379
+
3380
+ # --- FINAL LABEL POSITION PASS ---
3381
+ # Some downstream operations (e.g. CIF tick overlay extending y-limits or auto margin
3382
+ # adjustments by certain backends) can occur after the initial label placement,
3383
+ # leading to visibly misplaced curve labels on first show. We perform a final
3384
+ # synchronous draw + update_labels here to lock them to the correct coordinates
3385
+ # before any saving / interactive session starts. (Subsequent interactions still
3386
+ # use the existing callbacks / update logic.)
3387
+ try:
3388
+ fig.canvas.draw() # ensure limits are finalized
3389
+ update_labels(ax, y_data_list, label_text_objects, args.stack, False)
3390
+ except Exception:
3391
+ pass
3392
+
3393
+ # ---------------- Apply style file if provided ----------------
3394
+ if style_cfg:
3395
+ try:
3396
+ from .batch import _apply_xy_style
3397
+ _apply_xy_style(fig, ax, style_cfg)
3398
+ # Redraw after applying style
3399
+ fig.canvas.draw()
3400
+ except Exception as e:
3401
+ print(f"Warning: Error applying style file: {e}")
3402
+
3403
+ # ---------------- Save figure object ----------------
3404
+ if args.savefig:
3405
+ # Remove numbering for exported figure object (if ticks present)
3406
+ if cif_tick_series and 'cif_numbering_enabled' in globals() and cif_numbering_enabled:
3407
+ prev_num = cif_numbering_enabled
3408
+ cif_numbering_enabled = False
3409
+ if 'draw_cif_ticks' in globals():
3410
+ draw_cif_ticks()
3411
+ target = _confirm_overwrite(args.savefig)
3412
+ if target:
3413
+ with open(target, "wb") as f:
3414
+ pickle.dump(fig, f)
3415
+ cif_numbering_enabled = prev_num
3416
+ if 'draw_cif_ticks' in globals():
3417
+ draw_cif_ticks()
3418
+ else:
3419
+ target = _confirm_overwrite(args.savefig)
3420
+ if target:
3421
+ with open(target, "wb") as f:
3422
+ pickle.dump(fig, f)
3423
+ if target:
3424
+ print(f"Saved figure object to {target}")
3425
+
3426
+ # ---------------- Show and interactive menu ----------------
3427
+ if args.interactive:
3428
+ # Show the current figure once (non-blocking) so interactive menu updates reuse this window
3429
+ try:
3430
+ plt.ion()
3431
+ except Exception:
3432
+ pass
3433
+ try:
3434
+ # Using canvas draw without show first avoids new-window creation on some backends
3435
+ fig.canvas.draw_idle(); fig.canvas.flush_events()
3436
+ except Exception:
3437
+ pass
3438
+ try:
3439
+ plt.show(block=False)
3440
+ except Exception:
3441
+ pass
3442
+ # Increase default upper margin (more space): reduce 'top' value once and lock
3443
+ try:
3444
+ sp = fig.subplotpars
3445
+ if sp.top >= 0.88: # only if near default
3446
+ fig.subplots_adjust(top=0.88)
3447
+ fig._interactive_top_locked = True
3448
+ except Exception:
3449
+ pass
3450
+
3451
+ # CRITICAL: Disable automatic layout adjustments to ensure parameter independence
3452
+ # This prevents matplotlib from moving axes when labels are changed
3453
+ try:
3454
+ fig.set_layout_engine('none')
3455
+ except AttributeError:
3456
+ # Older matplotlib versions - disable tight_layout
3457
+ try:
3458
+ fig.set_tight_layout(False)
3459
+ except Exception:
3460
+ pass
3461
+
3462
+ # Build CIF globals dict for explicit passing
3463
+ cif_globals = {
3464
+ 'cif_tick_series': cif_tick_series,
3465
+ 'cif_hkl_map': cif_hkl_map,
3466
+ 'cif_hkl_label_map': cif_hkl_label_map,
3467
+ 'show_cif_hkl': show_cif_hkl,
3468
+ 'show_cif_titles': show_cif_titles,
3469
+ 'cif_extend_suspended': cif_extend_suspended,
3470
+ 'keep_canvas_fixed': keep_canvas_fixed,
3471
+ 'file_wavelength_info': file_wavelength_info,
3472
+ }
3473
+
3474
+ interactive_menu(
3475
+ fig, ax, y_data_list, x_data_list, labels_list,
3476
+ orig_y, label_text_objects, args.delta, x_label, args,
3477
+ x_full_list, raw_y_full_list, offsets_list,
3478
+ use_Q, use_r, use_E, use_k, use_rft,
3479
+ cif_globals=cif_globals,
3480
+ )
3481
+ elif args.out:
3482
+ out_file = args.out
3483
+ if not os.path.splitext(out_file)[1]:
3484
+ out_file += ".svg"
3485
+ # Confirm overwrite for export path
3486
+ export_target = _confirm_overwrite(out_file)
3487
+ if not export_target:
3488
+ print("Export canceled.")
3489
+ else:
3490
+ for i, txt in enumerate(label_text_objects):
3491
+ txt.set_text(labels_list[i])
3492
+ # Temporarily disable numbering for export
3493
+ if cif_tick_series and 'cif_numbering_enabled' in globals() and cif_numbering_enabled:
3494
+ prev_num = cif_numbering_enabled
3495
+ cif_numbering_enabled = False
3496
+ if 'draw_cif_ticks' in globals():
3497
+ draw_cif_ticks()
3498
+ # Transparent background for SVG exports
3499
+ _, _ext = os.path.splitext(export_target)
3500
+ if _ext.lower() == '.svg':
3501
+ try:
3502
+ _fig_fc = fig.get_facecolor()
3503
+ except Exception:
3504
+ _fig_fc = None
3505
+ try:
3506
+ _ax_fc = ax.get_facecolor()
3507
+ except Exception:
3508
+ _ax_fc = None
3509
+ try:
3510
+ if getattr(fig, 'patch', None) is not None:
3511
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
3512
+ if getattr(ax, 'patch', None) is not None:
3513
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
3514
+ except Exception:
3515
+ pass
3516
+ try:
3517
+ fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
3518
+ finally:
3519
+ try:
3520
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
3521
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
3522
+ except Exception:
3523
+ pass
3524
+ try:
3525
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
3526
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
3527
+ except Exception:
3528
+ pass
3529
+ else:
3530
+ fig.savefig(export_target, dpi=300)
3531
+ cif_numbering_enabled = prev_num
3532
+ if 'draw_cif_ticks' in globals():
3533
+ draw_cif_ticks()
3534
+ else:
3535
+ # Transparent background for SVG exports
3536
+ _, _ext = os.path.splitext(export_target)
3537
+ if _ext.lower() == '.svg':
3538
+ try:
3539
+ _fig_fc = fig.get_facecolor()
3540
+ except Exception:
3541
+ _fig_fc = None
3542
+ try:
3543
+ _ax_fc = ax.get_facecolor()
3544
+ except Exception:
3545
+ _ax_fc = None
3546
+ try:
3547
+ if getattr(fig, 'patch', None) is not None:
3548
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
3549
+ if getattr(ax, 'patch', None) is not None:
3550
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
3551
+ except Exception:
3552
+ pass
3553
+ try:
3554
+ fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
3555
+ finally:
3556
+ try:
3557
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
3558
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
3559
+ except Exception:
3560
+ pass
3561
+ try:
3562
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
3563
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
3564
+ except Exception:
3565
+ pass
3566
+ else:
3567
+ fig.savefig(export_target, dpi=300)
3568
+ print(f"Saved plot to {export_target}")
3569
+ else:
3570
+ # Default: show the plot in non-interactive, non-save mode
3571
+ try:
3572
+ _backend = plt.get_backend()
3573
+ except Exception:
3574
+ _backend = "unknown"
3575
+ # TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
3576
+ _interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
3577
+ _is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
3578
+ if not _is_noninteractive:
3579
+ plt.show()
3580
+ else:
3581
+ print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
3582
+
3583
+ # Success
3584
+ return 0
3585
+
3586
+
3587
+ # Entry point for CLI
3588
+ if __name__ == "__main__":
3589
+ sys.exit(batplot_main())