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

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

Potentially problematic release.


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

Files changed (38) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +10 -0
  5. batplot/interactive.py +10 -0
  6. batplot/modes.py +12 -12
  7. batplot/operando_ec_interactive.py +4 -4
  8. batplot/session.py +17 -0
  9. batplot/version_check.py +1 -1
  10. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  11. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
  12. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  13. batplot_backup_20251221_101150/__init__.py +5 -0
  14. batplot_backup_20251221_101150/args.py +625 -0
  15. batplot_backup_20251221_101150/batch.py +1176 -0
  16. batplot_backup_20251221_101150/batplot.py +3589 -0
  17. batplot_backup_20251221_101150/cif.py +823 -0
  18. batplot_backup_20251221_101150/cli.py +149 -0
  19. batplot_backup_20251221_101150/color_utils.py +547 -0
  20. batplot_backup_20251221_101150/config.py +198 -0
  21. batplot_backup_20251221_101150/converters.py +204 -0
  22. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  23. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  24. batplot_backup_20251221_101150/interactive.py +3894 -0
  25. batplot_backup_20251221_101150/manual.py +323 -0
  26. batplot_backup_20251221_101150/modes.py +799 -0
  27. batplot_backup_20251221_101150/operando.py +603 -0
  28. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  29. batplot_backup_20251221_101150/plotting.py +228 -0
  30. batplot_backup_20251221_101150/readers.py +2607 -0
  31. batplot_backup_20251221_101150/session.py +2951 -0
  32. batplot_backup_20251221_101150/style.py +1441 -0
  33. batplot_backup_20251221_101150/ui.py +790 -0
  34. batplot_backup_20251221_101150/utils.py +1046 -0
  35. batplot_backup_20251221_101150/version_check.py +253 -0
  36. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  37. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  38. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,4409 @@
1
+ """Interactive menu for Capacity-Per-Cycle (CPC) plots.
2
+
3
+ This module provides the interactive menu for CPC (Capacity Per Cycle) mode.
4
+ CPC plots show how battery capacity changes over multiple cycles, displaying:
5
+ - Charge capacity vs cycle number
6
+ - Discharge capacity vs cycle number
7
+ - Coulombic efficiency vs cycle number
8
+
9
+ HOW CPC MODE WORKS:
10
+ ------------------
11
+ CPC mode reads battery cycling data and extracts:
12
+ 1. Maximum charge capacity for each cycle
13
+ 2. Maximum discharge capacity for each cycle
14
+ 3. Coulombic efficiency = (discharge_capacity / charge_capacity) × 100%
15
+
16
+ These values are plotted as scatter points (one point per cycle), allowing you
17
+ to see capacity fade and efficiency trends over the battery's lifetime.
18
+
19
+ INTERACTIVE FEATURES:
20
+ --------------------
21
+ The interactive menu allows you to:
22
+ - Customize colors for each file (charge, discharge, efficiency)
23
+ - Adjust line/marker styles and sizes
24
+ - Show/hide individual files
25
+ - Modify axis ranges and labels
26
+ - Export style files (.bpcfg) for reuse
27
+ - Save/load sessions
28
+
29
+ MULTI-FILE SUPPORT:
30
+ -----------------
31
+ CPC mode can plot multiple files simultaneously, each with its own color scheme.
32
+ This is useful for comparing different battery cells, materials, or conditions.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ from typing import Dict, Optional
37
+ import json
38
+ import os
39
+ import sys
40
+ import contextlib
41
+ from io import StringIO
42
+
43
+ import matplotlib.pyplot as plt
44
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator
45
+ import random as _random
46
+
47
+
48
+ class _FilterIMKWarning:
49
+ """Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
50
+ def __init__(self, original_stderr):
51
+ self.original_stderr = original_stderr
52
+
53
+ def write(self, message):
54
+ # Filter out the harmless macOS IMK warning
55
+ if 'IMKCFRunLoopWakeUpReliable' not in message:
56
+ self.original_stderr.write(message)
57
+
58
+ def flush(self):
59
+ self.original_stderr.flush()
60
+
61
+
62
+ def _safe_input(prompt: str = "") -> str:
63
+ """Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
64
+
65
+ This is a harmless macOS system message that appears when using input() in terminals.
66
+ """
67
+ # Filter stderr to hide macOS IMK warnings while preserving other errors
68
+ original_stderr = sys.stderr
69
+ sys.stderr = _FilterIMKWarning(original_stderr)
70
+ try:
71
+ result = input(prompt)
72
+ return result
73
+ except (KeyboardInterrupt, EOFError):
74
+ raise
75
+ finally:
76
+ sys.stderr = original_stderr
77
+
78
+ from .ui import (
79
+ resize_plot_frame, resize_canvas,
80
+ update_tick_visibility as _ui_update_tick_visibility,
81
+ position_top_xlabel as _ui_position_top_xlabel,
82
+ position_right_ylabel as _ui_position_right_ylabel,
83
+ position_bottom_xlabel as _ui_position_bottom_xlabel,
84
+ position_left_ylabel as _ui_position_left_ylabel,
85
+ )
86
+ from .utils import (
87
+ _confirm_overwrite,
88
+ choose_save_path,
89
+ convert_label_shortcuts,
90
+ choose_style_file,
91
+ list_files_in_subdirectory,
92
+ get_organized_path,
93
+ )
94
+ import time
95
+ from .color_utils import resolve_color_token, color_block, palette_preview, manage_user_colors, get_user_color_list, ensure_colormap
96
+
97
+
98
+ def _legend_no_frame(ax, *args, **kwargs):
99
+ # Compact legend defaults and labelcolor matching marker/line color
100
+ kwargs.setdefault('frameon', False)
101
+ kwargs.setdefault('handlelength', 1.0)
102
+ kwargs.setdefault('handletextpad', 0.35)
103
+ kwargs.setdefault('labelspacing', 0.25)
104
+ kwargs.setdefault('borderaxespad', 0.5)
105
+ kwargs.setdefault('borderpad', 0.3)
106
+ kwargs.setdefault('columnspacing', 0.6)
107
+ # Let matplotlib color legend text from line/marker colors
108
+ kwargs.setdefault('labelcolor', 'linecolor')
109
+ leg = ax.legend(*args, **kwargs)
110
+ if leg is not None:
111
+ try:
112
+ leg.set_frame_on(False)
113
+ except Exception:
114
+ pass
115
+ return leg
116
+
117
+
118
+ def _visible_handles_labels(ax, ax2):
119
+ """Return handles/labels for visible artists only."""
120
+ try:
121
+ h1, l1 = ax.get_legend_handles_labels()
122
+ except Exception:
123
+ h1, l1 = [], []
124
+ try:
125
+ h2, l2 = ax2.get_legend_handles_labels()
126
+ except Exception:
127
+ h2, l2 = [], []
128
+ H, L = [], []
129
+ for h, l in list(zip(h1, l1)) + list(zip(h2, l2)):
130
+ try:
131
+ if hasattr(h, 'get_visible') and not h.get_visible():
132
+ continue
133
+ except Exception:
134
+ pass
135
+ H.append(h); L.append(l)
136
+ return H, L
137
+
138
+ def _colorize_menu(text):
139
+ """Colorize menu items: command in cyan, colon in white, description in default."""
140
+ if ':' not in text:
141
+ return text
142
+ parts = text.split(':', 1)
143
+ cmd = parts[0].strip()
144
+ desc = parts[1].strip() if len(parts) > 1 else ''
145
+ return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
146
+
147
+
148
+ def _color_of(artist):
149
+ """Return a representative color for a Line2D/PathCollection."""
150
+ try:
151
+ if artist is None:
152
+ return None
153
+ if hasattr(artist, 'get_color'):
154
+ c = artist.get_color()
155
+ if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
156
+ return c[0]
157
+ return c
158
+ if hasattr(artist, 'get_facecolors'):
159
+ arr = artist.get_facecolors()
160
+ if arr is not None and len(arr):
161
+ from matplotlib.colors import to_hex
162
+ return to_hex(arr[0])
163
+ except Exception:
164
+ return None
165
+ return None
166
+
167
+
168
+ def _get_legend_title(fig, default: Optional[str] = None) -> Optional[str]:
169
+ """Fetch stored legend title, falling back to current legend text or None."""
170
+ try:
171
+ title = getattr(fig, '_cpc_legend_title', None)
172
+ if isinstance(title, str) and title:
173
+ return title
174
+ except Exception:
175
+ pass
176
+ try:
177
+ for ax in getattr(fig, 'axes', []):
178
+ leg = ax.get_legend()
179
+ if leg is not None:
180
+ t = leg.get_title().get_text()
181
+ if t:
182
+ return t
183
+ except Exception:
184
+ pass
185
+ return default
186
+
187
+
188
+ def _colorize_prompt(text):
189
+ """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
190
+ import re
191
+ pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
192
+
193
+ def colorize_match(match):
194
+ content = match.group(1)
195
+ if '/' in content:
196
+ parts = content.split('/')
197
+ colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
198
+ return f"({'/'.join(colored_parts)})"
199
+ else:
200
+ parts = content.split(',')
201
+ colored_parts = []
202
+ for part in parts:
203
+ part = part.strip()
204
+ if '=' in part:
205
+ cmd, desc = part.split('=', 1)
206
+ colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
207
+ else:
208
+ colored_parts.append(part)
209
+ return f"({', '.join(colored_parts)})"
210
+
211
+ return re.sub(pattern, colorize_match, text)
212
+
213
+
214
+ def _colorize_inline_commands(text):
215
+ """Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
216
+ import re
217
+ # Color quoted command examples (like 's2 w5 a4', 'w2 w5')
218
+ text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
219
+ # Color specific known commands: q, i, l, list, help, all
220
+ text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
221
+ return text
222
+
223
+
224
+ def _collect_file_paths(file_data) -> list:
225
+ """Extract absolute file paths from file_data structures."""
226
+ paths = []
227
+ if isinstance(file_data, list):
228
+ for entry in file_data:
229
+ if isinstance(entry, dict):
230
+ path = entry.get('filepath')
231
+ if path:
232
+ paths.append(path)
233
+ elif isinstance(file_data, dict):
234
+ path = file_data.get('filepath')
235
+ if path:
236
+ paths.append(path)
237
+ return paths
238
+
239
+
240
+ def _generate_similar_color(base_color):
241
+ """Generate a similar but distinguishable color for discharge from charge color."""
242
+ try:
243
+ from matplotlib.colors import to_rgb, rgb_to_hsv, hsv_to_rgb
244
+ import numpy as np
245
+
246
+ # Convert to RGB
247
+ rgb = to_rgb(base_color)
248
+ # Convert to HSV
249
+ hsv = rgb_to_hsv(rgb)
250
+
251
+ # Adjust hue slightly (+/- 15 degrees) and reduce saturation/brightness slightly
252
+ h, s, v = hsv
253
+ h_new = (h + 0.04) % 1.0 # Shift hue slightly
254
+ s_new = max(0.3, s * 0.85) # Reduce saturation
255
+ v_new = max(0.4, v * 0.9) # Slightly darker
256
+
257
+ # Convert back to RGB
258
+ rgb_new = hsv_to_rgb([h_new, s_new, v_new])
259
+ return rgb_new
260
+ except Exception:
261
+ # Fallback to a darker version
262
+ try:
263
+ from matplotlib.colors import to_rgb
264
+ rgb = to_rgb(base_color)
265
+ return tuple(max(0, c * 0.7) for c in rgb)
266
+ except Exception:
267
+ return base_color
268
+
269
+
270
+ def _print_menu():
271
+ col1 = [
272
+ " f: font",
273
+ " l: line",
274
+ " m: marker sizes",
275
+ " c: colors",
276
+ " k: spine colors",
277
+ "ry: show/hide efficiency",
278
+ " t: toggle axes",
279
+ " h: legend",
280
+ " g: size",
281
+ " v: show/hide files",
282
+ ]
283
+ col2 = [
284
+ "r: rename",
285
+ "x: x range",
286
+ "y: y ranges",
287
+ ]
288
+ col3 = [
289
+ "p: print(export) style/geom",
290
+ "i: import style/geom",
291
+ "e: export figure",
292
+ "s: save project",
293
+ "b: undo",
294
+ "q: quit",
295
+ ]
296
+ w1 = max(18, *(len(s) for s in col1))
297
+ w2 = max(18, *(len(s) for s in col2))
298
+ w3 = max(12, *(len(s) for s in col3))
299
+ rows = max(len(col1), len(col2), len(col3))
300
+ print("\n\033[1mCPC interactive menu:\033[0m") # Bold title
301
+ print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
302
+ for i in range(rows):
303
+ p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
304
+ p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
305
+ p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
306
+ # Add padding to account for ANSI escape codes
307
+ pad1 = w1 + (9 if i < len(col1) else 0)
308
+ pad2 = w2 + (9 if i < len(col2) else 0)
309
+ pad3 = w3 + (9 if i < len(col3) else 0)
310
+ print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
311
+
312
+
313
+ def _get_current_file_artists(file_data, current_idx):
314
+ """Get the scatter artists for the currently selected file."""
315
+ if not file_data or current_idx >= len(file_data):
316
+ return None, None, None
317
+ file_info = file_data[current_idx]
318
+ return file_info['sc_charge'], file_info['sc_discharge'], file_info['sc_eff']
319
+
320
+
321
+ def _print_file_list(file_data, current_idx):
322
+ """Print list of files with current selection highlighted."""
323
+ print("\n=== Files ===")
324
+ for i, f in enumerate(file_data):
325
+ marker = "→" if i == current_idx else " "
326
+ vis = "✓" if f.get('visible', True) else "✗"
327
+ print(f"{marker} {i+1}. [{vis}] {f['filename']}")
328
+ print()
329
+
330
+
331
+ def _rebuild_legend(ax, ax2, file_data, preserve_position=True):
332
+ """Rebuild legend from all visible files.
333
+
334
+ Args:
335
+ preserve_position: If True, preserve legend position after rebuilding.
336
+ """
337
+ try:
338
+ fig = ax.figure
339
+ # Get stored position before rebuilding. If none is stored yet, try to
340
+ # capture the current on-canvas position once so subsequent rebuilds
341
+ # (e.g., after renaming) do not jump to a new "best" location.
342
+ xy_in = None
343
+ if preserve_position:
344
+ try:
345
+ xy_in = getattr(fig, '_cpc_legend_xy_in', None)
346
+ except Exception:
347
+ xy_in = None
348
+ if xy_in is None:
349
+ try:
350
+ leg0 = ax.get_legend()
351
+ if leg0 is not None and leg0.get_visible():
352
+ try:
353
+ renderer = fig.canvas.get_renderer()
354
+ except Exception:
355
+ fig.canvas.draw()
356
+ renderer = fig.canvas.get_renderer()
357
+ bb = leg0.get_window_extent(renderer=renderer)
358
+ cx = 0.5 * (bb.x0 + bb.x1)
359
+ cy = 0.5 * (bb.y0 + bb.y1)
360
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
361
+ fw, fh = fig.get_size_inches()
362
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
363
+ offset = _sanitize_legend_offset(offset)
364
+ if offset is not None:
365
+ fig._cpc_legend_xy_in = offset
366
+ xy_in = offset
367
+ except Exception:
368
+ pass
369
+
370
+ h1, l1 = ax.get_legend_handles_labels()
371
+ h2, l2 = ax2.get_legend_handles_labels()
372
+ # Filter to only visible items
373
+ h_all, l_all = [], []
374
+ for h, l in zip(h1 + h2, l1 + l2):
375
+ if h.get_visible():
376
+ h_all.append(h)
377
+ l_all.append(l)
378
+
379
+ if h_all:
380
+ # Get legend title (None if not set, to avoid showing "Legend")
381
+ leg_title = _get_legend_title(fig, default=None)
382
+
383
+ if xy_in is not None and preserve_position:
384
+ # Use stored position
385
+ try:
386
+ fw, fh = fig.get_size_inches()
387
+ fx = 0.5 + float(xy_in[0]) / float(fw)
388
+ fy = 0.5 + float(xy_in[1]) / float(fh)
389
+ _legend_no_frame(ax, h_all, l_all, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0, title=leg_title)
390
+ except Exception:
391
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
392
+ else:
393
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
394
+ else:
395
+ leg = ax.get_legend()
396
+ if leg:
397
+ leg.set_visible(False)
398
+ except Exception:
399
+ pass
400
+
401
+
402
+ def _get_geometry_snapshot(ax, ax2) -> Dict:
403
+ """Collects a snapshot of geometry settings (axes labels and limits)."""
404
+ geom = {
405
+ 'xlim': list(ax.get_xlim()),
406
+ 'ylim_left': list(ax.get_ylim()),
407
+ 'xlabel': ax.get_xlabel() or '',
408
+ 'ylabel_left': ax.get_ylabel() or '',
409
+ }
410
+ if ax2 is not None:
411
+ geom['ylim_right'] = list(ax2.get_ylim())
412
+ geom['ylabel_right'] = ax2.get_ylabel() or ''
413
+ return geom
414
+
415
+
416
+ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=None) -> Dict:
417
+ try:
418
+ fig_w, fig_h = map(float, fig.get_size_inches())
419
+ except Exception:
420
+ fig_w = fig_h = None
421
+
422
+ def _color_of(artist) -> Optional[str]:
423
+ try:
424
+ if hasattr(artist, 'get_color'):
425
+ c = artist.get_color()
426
+ # scatter returns array sometimes; pick first
427
+ if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
428
+ return c[0]
429
+ return c
430
+ if hasattr(artist, 'get_facecolors'):
431
+ arr = artist.get_facecolors()
432
+ if arr is not None and len(arr):
433
+ from matplotlib.colors import to_hex
434
+ return to_hex(arr[0])
435
+ except Exception:
436
+ pass
437
+ return None
438
+
439
+ fam = plt.rcParams.get('font.sans-serif', [''])
440
+ fam0 = fam[0] if fam else ''
441
+ fsize = plt.rcParams.get('font.size', None)
442
+ # Tick widths helper
443
+ def _tick_width(axis_obj, which: str):
444
+ try:
445
+ tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
446
+ width = tick_kw.get('width')
447
+ if width is None:
448
+ axis_name = getattr(axis_obj, 'axis_name', 'x')
449
+ rc_key = f"{axis_name}tick.{which}.width"
450
+ width = plt.rcParams.get(rc_key)
451
+ if width is not None:
452
+ return float(width)
453
+ except Exception:
454
+ return None
455
+ return None
456
+
457
+ def _label_visible(lbl):
458
+ try:
459
+ return bool(lbl.get_visible()) and bool(lbl.get_text())
460
+ except Exception:
461
+ return bool(lbl.get_text()) if hasattr(lbl, 'get_text') else False
462
+
463
+ # Current tick visibility (prefer persisted WASD state when available)
464
+ tick_vis = {
465
+ 'bx': True, 'tx': False, 'ly': True, 'ry': True,
466
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
467
+ }
468
+ try:
469
+ wasd_from_fig = getattr(fig, '_cpc_wasd_state', None)
470
+ if isinstance(wasd_from_fig, dict) and wasd_from_fig:
471
+ # Use stored state (authoritative)
472
+ tick_vis['bx'] = bool(wasd_from_fig.get('bottom', {}).get('labels', True))
473
+ tick_vis['tx'] = bool(wasd_from_fig.get('top', {}).get('labels', False))
474
+ tick_vis['ly'] = bool(wasd_from_fig.get('left', {}).get('labels', True))
475
+ tick_vis['ry'] = bool(wasd_from_fig.get('right', {}).get('labels', True))
476
+ tick_vis['mbx'] = bool(wasd_from_fig.get('bottom', {}).get('minor', False))
477
+ tick_vis['mtx'] = bool(wasd_from_fig.get('top', {}).get('minor', False))
478
+ tick_vis['mly'] = bool(wasd_from_fig.get('left', {}).get('minor', False))
479
+ tick_vis['mry'] = bool(wasd_from_fig.get('right', {}).get('minor', False))
480
+ else:
481
+ # Infer from current axes state
482
+ tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
483
+ tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
484
+ tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
485
+ tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
486
+ except Exception:
487
+ pass
488
+
489
+ # Plot frame size
490
+ ax_bbox = ax.get_position()
491
+ frame_w_in = ax_bbox.width * fig_w if fig_w else None
492
+ frame_h_in = ax_bbox.height * fig_h if fig_h else None
493
+
494
+ # Build WASD-style state (20 parameters: 4 sides × 5 properties)
495
+ # CPC: bottom/top are X-axis, left is primary Y (capacity), right is twin Y (efficiency)
496
+ def _get_spine_visible(ax_obj, which: str) -> bool:
497
+ sp = ax_obj.spines.get(which)
498
+ try:
499
+ return bool(sp.get_visible()) if sp is not None else False
500
+ except Exception:
501
+ return False
502
+
503
+ wasd_state = getattr(fig, '_cpc_wasd_state', None)
504
+ if not isinstance(wasd_state, dict) or not wasd_state:
505
+ wasd_state = {
506
+ 'bottom': {
507
+ 'spine': _get_spine_visible(ax, 'bottom'),
508
+ 'ticks': bool(tick_vis.get('bx', True)),
509
+ 'minor': bool(tick_vis.get('mbx', False)),
510
+ 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
511
+ 'title': bool(ax.get_xlabel()) # bottom x title
512
+ },
513
+ 'top': {
514
+ 'spine': _get_spine_visible(ax, 'top'),
515
+ 'ticks': bool(tick_vis.get('tx', False)),
516
+ 'minor': bool(tick_vis.get('mtx', False)),
517
+ 'labels': bool(tick_vis.get('tx', False)),
518
+ 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
519
+ },
520
+ 'left': {
521
+ 'spine': _get_spine_visible(ax, 'left'),
522
+ 'ticks': bool(tick_vis.get('ly', True)),
523
+ 'minor': bool(tick_vis.get('mly', False)),
524
+ 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
525
+ 'title': _label_visible(ax.yaxis.label) # left y title
526
+ },
527
+ 'right': {
528
+ 'spine': _get_spine_visible(ax2, 'right'),
529
+ 'ticks': bool(tick_vis.get('ry', True)),
530
+ 'minor': bool(tick_vis.get('mry', False)),
531
+ 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
532
+ 'title': _label_visible(ax2.yaxis.label) # right y title respects visibility
533
+ },
534
+ }
535
+
536
+ # Capture legend state
537
+ legend_visible = False
538
+ legend_xy_in = None
539
+ try:
540
+ leg = ax.get_legend()
541
+ if leg is not None:
542
+ legend_visible = leg.get_visible()
543
+ # Get legend position stored in figure attribute
544
+ legend_xy_in = getattr(fig, '_cpc_legend_xy_in', None)
545
+ except Exception:
546
+ pass
547
+
548
+ # Grid state
549
+ grid_enabled = False
550
+ try:
551
+ # Check if grid is currently on by looking at gridline visibility
552
+ for line in ax.get_xgridlines() + ax.get_ygridlines():
553
+ if line.get_visible():
554
+ grid_enabled = True
555
+ break
556
+ except Exception:
557
+ grid_enabled = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
558
+
559
+ cfg = {
560
+ 'kind': 'cpc_style',
561
+ 'version': 2,
562
+ 'figure': {
563
+ 'canvas_size': [fig_w, fig_h],
564
+ 'frame_size': [frame_w_in, frame_h_in],
565
+ 'axes_fraction': [ax_bbox.x0, ax_bbox.y0, ax_bbox.width, ax_bbox.height]
566
+ },
567
+ 'font': {'family': fam0, 'size': fsize},
568
+ 'legend': {
569
+ 'visible': legend_visible,
570
+ 'position_inches': legend_xy_in, # [x, y] offset from canvas center in inches
571
+ 'title': _get_legend_title(fig),
572
+ },
573
+ 'ticks': {
574
+ 'widths': {
575
+ 'x_major': _tick_width(ax.xaxis, 'major'),
576
+ 'x_minor': _tick_width(ax.xaxis, 'minor'),
577
+ 'ly_major': _tick_width(ax.yaxis, 'major'),
578
+ 'ly_minor': _tick_width(ax.yaxis, 'minor'),
579
+ 'ry_major': _tick_width(ax2.yaxis, 'major'),
580
+ 'ry_minor': _tick_width(ax2.yaxis, 'minor'),
581
+ },
582
+ 'lengths': dict(getattr(fig, '_tick_lengths', {'major': None, 'minor': None})),
583
+ 'direction': getattr(fig, '_tick_direction', 'out')
584
+ },
585
+ 'grid': grid_enabled,
586
+ 'wasd_state': wasd_state,
587
+ 'spines': {
588
+ 'bottom': {'linewidth': ax.spines.get('bottom').get_linewidth() if ax.spines.get('bottom') else None,
589
+ 'visible': ax.spines.get('bottom').get_visible() if ax.spines.get('bottom') else None,
590
+ 'color': ax.spines.get('bottom').get_edgecolor() if ax.spines.get('bottom') else None},
591
+ 'top': {'linewidth': ax.spines.get('top').get_linewidth() if ax.spines.get('top') else None,
592
+ 'visible': ax.spines.get('top').get_visible() if ax.spines.get('top') else None,
593
+ 'color': ax.spines.get('top').get_edgecolor() if ax.spines.get('top') else None},
594
+ 'left': {'linewidth': ax.spines.get('left').get_linewidth() if ax.spines.get('left') else None,
595
+ 'visible': ax.spines.get('left').get_visible() if ax.spines.get('left') else None,
596
+ 'color': ax.spines.get('left').get_edgecolor() if ax.spines.get('left') else None},
597
+ 'right': {'linewidth': ax2.spines.get('right').get_linewidth() if ax2.spines.get('right') else None,
598
+ 'visible': ax2.spines.get('right').get_visible() if ax2.spines.get('right') else None,
599
+ 'color': ax2.spines.get('right').get_edgecolor() if ax2.spines.get('right') else None},
600
+ },
601
+ 'spine_colors_auto': getattr(fig, '_cpc_spine_auto', False),
602
+ 'spine_colors': dict(getattr(fig, '_cpc_spine_colors', {})),
603
+ 'labelpads': {
604
+ 'x': getattr(ax.xaxis, 'labelpad', None),
605
+ 'ly': getattr(ax.yaxis, 'labelpad', None), # left y-axis (capacity)
606
+ 'ry': getattr(ax2.yaxis, 'labelpad', None), # right y-axis (efficiency)
607
+ },
608
+ 'title_offsets': {
609
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
610
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
611
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
612
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
613
+ 'right_x': float(getattr(ax2, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
614
+ 'right_y': float(getattr(ax2, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
615
+ },
616
+ 'series': {
617
+ 'charge': {
618
+ 'color': _color_of(sc_charge),
619
+ 'marker': getattr(sc_charge, 'get_marker', lambda: 'o')(),
620
+ 'markersize': float(getattr(sc_charge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_charge, 'get_sizes') else 32.0,
621
+ 'alpha': float(sc_charge.get_alpha()) if sc_charge.get_alpha() is not None else 1.0,
622
+ },
623
+ 'discharge': {
624
+ 'color': _color_of(sc_discharge),
625
+ 'marker': getattr(sc_discharge, 'get_marker', lambda: 's')(),
626
+ 'markersize': float(getattr(sc_discharge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_discharge, 'get_sizes') else 32.0,
627
+ 'alpha': float(sc_discharge.get_alpha()) if sc_discharge.get_alpha() is not None else 1.0,
628
+ },
629
+ 'efficiency': {
630
+ 'color': (sc_eff.get_facecolors()[0].tolist() if hasattr(sc_eff, 'get_facecolors') and len(sc_eff.get_facecolors()) else '#2ca02c'),
631
+ 'marker': getattr(sc_eff, 'get_marker', lambda: '^')(),
632
+ 'markersize': float(getattr(sc_eff, 'get_sizes', lambda: [40])()[0]) if hasattr(sc_eff, 'get_sizes') else 40.0,
633
+ 'alpha': float(sc_eff.get_alpha()) if sc_eff.get_alpha() is not None else 1.0,
634
+ 'visible': bool(getattr(sc_eff, 'get_visible', lambda: True)()),
635
+ }
636
+ }
637
+ }
638
+
639
+ # Add multi-file data if available
640
+ if file_data and isinstance(file_data, list) and len(file_data) > 0:
641
+ multi_files = []
642
+ for f in file_data:
643
+ sc_chg = f.get('sc_charge')
644
+ sc_dchg = f.get('sc_discharge')
645
+ sc_eff = f.get('sc_eff')
646
+ file_info = {
647
+ 'filename': f.get('filename', 'unknown'),
648
+ 'visible': f.get('visible', True),
649
+ 'charge_color': _color_of(sc_chg),
650
+ 'charge_marker': getattr(sc_chg, 'get_marker', lambda: 'o')() if sc_chg else 'o',
651
+ 'discharge_color': _color_of(sc_dchg),
652
+ 'discharge_marker': getattr(sc_dchg, 'get_marker', lambda: 's')() if sc_dchg else 's',
653
+ 'efficiency_color': _color_of(sc_eff),
654
+ 'efficiency_marker': getattr(sc_eff, 'get_marker', lambda: '^')() if sc_eff else '^',
655
+ }
656
+ # Save legend labels
657
+ try:
658
+ sc_chg = f.get('sc_charge')
659
+ sc_dchg = f.get('sc_discharge')
660
+ sc_eff = f.get('sc_eff')
661
+ if sc_chg and hasattr(sc_chg, 'get_label'):
662
+ file_info['charge_label'] = sc_chg.get_label() or ''
663
+ if sc_dchg and hasattr(sc_dchg, 'get_label'):
664
+ file_info['discharge_label'] = sc_dchg.get_label() or ''
665
+ if sc_eff and hasattr(sc_eff, 'get_label'):
666
+ file_info['efficiency_label'] = sc_eff.get_label() or ''
667
+ except Exception:
668
+ pass
669
+ multi_files.append(file_info)
670
+ cfg['multi_files'] = multi_files
671
+ else:
672
+ # Single file mode: save legend labels
673
+ try:
674
+ cfg['series']['charge']['label'] = sc_charge.get_label() if hasattr(sc_charge, 'get_label') else ''
675
+ cfg['series']['discharge']['label'] = sc_discharge.get_label() if hasattr(sc_discharge, 'get_label') else ''
676
+ cfg['series']['efficiency']['label'] = sc_eff.get_label() if hasattr(sc_eff, 'get_label') else ''
677
+ except Exception:
678
+ pass
679
+
680
+ return cfg
681
+
682
+
683
+ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_data=None):
684
+ """Apply style configuration to CPC plot.
685
+
686
+ Args:
687
+ fig, ax, ax2: Matplotlib figure and axes
688
+ sc_charge, sc_discharge, sc_eff: Primary/selected file scatter artists
689
+ cfg: Style configuration dict
690
+ file_data: Optional list of file dicts for multi-file mode
691
+ """
692
+ is_multi_file = file_data is not None and len(file_data) > 1
693
+
694
+ # Save current labelpad values BEFORE any style changes
695
+ saved_xlabelpad = None
696
+ saved_ylabelpad = None
697
+ saved_rylabelpad = None
698
+ try:
699
+ saved_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
700
+ except Exception:
701
+ pass
702
+ try:
703
+ saved_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
704
+ except Exception:
705
+ pass
706
+ try:
707
+ saved_rylabelpad = getattr(ax2.yaxis, 'labelpad', None) if ax2 is not None else None
708
+ except Exception:
709
+ pass
710
+
711
+ try:
712
+ font = cfg.get('font', {})
713
+ fam = font.get('family')
714
+ size = font.get('size')
715
+ if fam:
716
+ plt.rcParams['font.family'] = 'sans-serif'
717
+ plt.rcParams['font.sans-serif'] = [fam, 'DejaVu Sans', 'Arial', 'Helvetica']
718
+ if size is not None:
719
+ plt.rcParams['font.size'] = float(size)
720
+ # Apply to current axes tick labels and duplicate artists, if present
721
+ if fam or size is not None:
722
+ fam0 = fam if fam else None
723
+ sz = float(size) if size is not None else None
724
+ for a in (ax, ax2):
725
+ try:
726
+ if sz is not None:
727
+ a.xaxis.label.set_size(sz); a.yaxis.label.set_size(sz)
728
+ if fam0:
729
+ a.xaxis.label.set_family(fam0); a.yaxis.label.set_family(fam0)
730
+ except Exception:
731
+ pass
732
+ try:
733
+ labels = a.get_xticklabels() + a.get_yticklabels()
734
+ for t in labels:
735
+ if sz is not None: t.set_size(sz)
736
+ if fam0: t.set_family(fam0)
737
+ except Exception:
738
+ pass
739
+ # Top/right tick labels (label2)
740
+ try:
741
+ for t in a.xaxis.get_major_ticks():
742
+ if hasattr(t, 'label2'):
743
+ if sz is not None: t.label2.set_size(sz)
744
+ if fam0: t.label2.set_family(fam0)
745
+ for t in a.yaxis.get_major_ticks():
746
+ if hasattr(t, 'label2'):
747
+ if sz is not None: t.label2.set_size(sz)
748
+ if fam0: t.label2.set_family(fam0)
749
+ except Exception:
750
+ pass
751
+ try:
752
+ art = getattr(ax, '_top_xlabel_artist', None)
753
+ if art is not None:
754
+ if sz is not None: art.set_fontsize(sz)
755
+ if fam0: art.set_fontfamily(fam0)
756
+ except Exception:
757
+ pass
758
+ try:
759
+ art = getattr(ax, '_right_ylabel_artist', None)
760
+ if art is not None:
761
+ if sz is not None: art.set_fontsize(sz)
762
+ if fam0: art.set_fontfamily(fam0)
763
+ except Exception:
764
+ pass
765
+ except Exception:
766
+ pass
767
+ # Apply canvas and frame size (from 'g' command: plot frame and canvas)
768
+ try:
769
+ fig_cfg = cfg.get('figure', {})
770
+ # Get axes_fraction BEFORE changing canvas size (to preserve exact position)
771
+ axes_frac = fig_cfg.get('axes_fraction')
772
+ frame_size = fig_cfg.get('frame_size')
773
+
774
+ canvas_size = fig_cfg.get('canvas_size')
775
+ if canvas_size and isinstance(canvas_size, (list, tuple)) and len(canvas_size) == 2:
776
+ # Use forward=False to prevent automatic subplot adjustment that can shift the plot
777
+ fig.set_size_inches(canvas_size[0], canvas_size[1], forward=False)
778
+
779
+ # Frame position: prefer axes_fraction (exact position), fall back to preserving position with frame_size
780
+ if axes_frac and isinstance(axes_frac, (list, tuple)) and len(axes_frac) == 4:
781
+ # Restore exact position from axes_fraction (this overrides any automatic adjustments)
782
+ x0, y0, w, h = axes_frac
783
+ ax.set_position([float(x0), float(y0), float(w), float(h)])
784
+ elif frame_size:
785
+ # Fall back to preserving current position with frame_size (for backward compatibility)
786
+ if frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
787
+ fw_in, fh_in = frame_size
788
+ canvas_w, canvas_h = fig.get_size_inches()
789
+ if canvas_w > 0 and canvas_h > 0:
790
+ # Keep current left/bottom position, adjust width/height
791
+ current_pos = ax.get_position()
792
+ new_w = fw_in / canvas_w
793
+ new_h = fh_in / canvas_h
794
+ ax.set_position([current_pos.x0, current_pos.y0, new_w, new_h])
795
+ except Exception:
796
+ pass
797
+ try:
798
+ s = cfg.get('series', {})
799
+ ch = s.get('charge', {})
800
+ dh = s.get('discharge', {})
801
+ ef = s.get('efficiency', {})
802
+
803
+ # Apply marker sizes and alpha globally to all files in multi-file mode
804
+ if is_multi_file:
805
+ for f in file_data:
806
+ # Marker types (global)
807
+ if ch.get('marker') is not None and hasattr(f['sc_charge'], 'set_marker'):
808
+ f['sc_charge'].set_marker(ch['marker'])
809
+ if dh.get('marker') is not None and hasattr(f['sc_discharge'], 'set_marker'):
810
+ f['sc_discharge'].set_marker(dh['marker'])
811
+ if ef.get('marker') is not None and hasattr(f['sc_eff'], 'set_marker'):
812
+ f['sc_eff'].set_marker(ef['marker'])
813
+ # Marker sizes (global)
814
+ if ch.get('markersize') is not None and hasattr(f['sc_charge'], 'set_sizes'):
815
+ f['sc_charge'].set_sizes([float(ch['markersize'])])
816
+ if dh.get('markersize') is not None and hasattr(f['sc_discharge'], 'set_sizes'):
817
+ f['sc_discharge'].set_sizes([float(dh['markersize'])])
818
+ if ef.get('markersize') is not None and hasattr(f['sc_eff'], 'set_sizes'):
819
+ f['sc_eff'].set_sizes([float(ef['markersize'])])
820
+
821
+ # Alpha (global)
822
+ if ch.get('alpha') is not None:
823
+ f['sc_charge'].set_alpha(float(ch['alpha']))
824
+ if dh.get('alpha') is not None:
825
+ f['sc_discharge'].set_alpha(float(dh['alpha']))
826
+ if ef.get('alpha') is not None:
827
+ f['sc_eff'].set_alpha(float(ef['alpha']))
828
+
829
+ # Efficiency visibility (global)
830
+ if 'visible' in ef:
831
+ eff_vis = bool(ef['visible'])
832
+ for f in file_data:
833
+ try:
834
+ f['sc_eff'].set_visible(eff_vis)
835
+ except Exception:
836
+ pass
837
+ try:
838
+ ax2.yaxis.label.set_visible(eff_vis)
839
+ except Exception:
840
+ pass
841
+ else:
842
+ # Single file mode: apply to provided artists only
843
+ if ch:
844
+ if ch.get('color') is not None:
845
+ sc_charge.set_color(ch['color'])
846
+ if ch.get('marker') is not None and hasattr(sc_charge, 'set_marker'):
847
+ sc_charge.set_marker(ch['marker'])
848
+ if ch.get('markersize') is not None and hasattr(sc_charge, 'set_sizes'):
849
+ sc_charge.set_sizes([float(ch['markersize'])])
850
+ if ch.get('alpha') is not None:
851
+ sc_charge.set_alpha(float(ch['alpha']))
852
+ if dh:
853
+ if dh.get('color') is not None:
854
+ sc_discharge.set_color(dh['color'])
855
+ if dh.get('marker') is not None and hasattr(sc_discharge, 'set_marker'):
856
+ sc_discharge.set_marker(dh['marker'])
857
+ if dh.get('markersize') is not None and hasattr(sc_discharge, 'set_sizes'):
858
+ sc_discharge.set_sizes([float(dh['markersize'])])
859
+ if dh.get('alpha') is not None:
860
+ sc_discharge.set_alpha(float(dh['alpha']))
861
+ if ef:
862
+ if ef.get('color') is not None:
863
+ try:
864
+ sc_eff.set_color(ef['color'])
865
+ except Exception:
866
+ pass
867
+ if ef.get('marker') is not None and hasattr(sc_eff, 'set_marker'):
868
+ sc_eff.set_marker(ef['marker'])
869
+ if ef.get('markersize') is not None and hasattr(sc_eff, 'set_sizes'):
870
+ sc_eff.set_sizes([float(ef['markersize'])])
871
+ if ef.get('alpha') is not None:
872
+ sc_eff.set_alpha(float(ef['alpha']))
873
+ if 'visible' in ef:
874
+ try:
875
+ sc_eff.set_visible(bool(ef['visible']))
876
+ ax2.yaxis.label.set_visible(bool(ef['visible']))
877
+ except Exception:
878
+ pass
879
+ except Exception:
880
+ pass
881
+ # Apply legend state (h command)
882
+ try:
883
+ leg_cfg = cfg.get('legend', {})
884
+ if leg_cfg:
885
+ leg_visible = leg_cfg.get('visible', True)
886
+ leg_xy_in = leg_cfg.get('position_inches')
887
+ if 'title' in leg_cfg:
888
+ fig._cpc_legend_title = leg_cfg.get('title') or _get_legend_title(fig)
889
+ if leg_xy_in is not None:
890
+ fig._cpc_legend_xy_in = _sanitize_legend_offset(tuple(leg_xy_in))
891
+ leg = ax.get_legend()
892
+ if leg is not None:
893
+ leg.set_visible(leg_visible)
894
+ if leg_visible:
895
+ _apply_legend_position()
896
+ # Re-apply legend label colors to match handles after position/visibility changes
897
+ try:
898
+ leg = ax.get_legend()
899
+ if leg is not None:
900
+ handles = list(getattr(leg, "legendHandles", []))
901
+ for h, txt in zip(handles, leg.get_texts()):
902
+ col = _color_of(h)
903
+ if col is None and hasattr(h, 'get_edgecolor'):
904
+ col = h.get_edgecolor()
905
+ if isinstance(col, (list, tuple)) and len(col) and not isinstance(col, str):
906
+ col = col[0]
907
+ try:
908
+ import numpy as _np
909
+ if hasattr(col, "__len__") and not isinstance(col, str):
910
+ col = tuple(_np.array(col).ravel().tolist())
911
+ except Exception:
912
+ pass
913
+ if col is not None:
914
+ txt.set_color(col)
915
+ except Exception:
916
+ pass
917
+ except Exception:
918
+ pass
919
+ # Apply tick visibility/widths and spines
920
+ try:
921
+ tk = cfg.get('ticks', {})
922
+ # Try wasd_state first (version 2), fall back to visibility dict (version 1)
923
+ wasd = cfg.get('wasd_state', {})
924
+ if isinstance(wasd, dict) and wasd:
925
+ try:
926
+ setattr(fig, '_cpc_wasd_state', wasd)
927
+ except Exception:
928
+ pass
929
+ if wasd:
930
+ # Use WASD state (20 parameters)
931
+ bx = bool(wasd.get('bottom', {}).get('labels', True))
932
+ tx = bool(wasd.get('top', {}).get('labels', False))
933
+ ly = bool(wasd.get('left', {}).get('labels', True))
934
+ ry = bool(wasd.get('right', {}).get('labels', True))
935
+ mbx = bool(wasd.get('bottom', {}).get('minor', False))
936
+ mtx = bool(wasd.get('top', {}).get('minor', False))
937
+ mly = bool(wasd.get('left', {}).get('minor', False))
938
+ mry = bool(wasd.get('right', {}).get('minor', False))
939
+ else:
940
+ # Fall back to old visibility dict
941
+ vis = tk.get('visibility', {})
942
+ bx = bool(vis.get('bx', True))
943
+ tx = bool(vis.get('tx', False))
944
+ ly = bool(vis.get('ly', True))
945
+ ry = bool(vis.get('ry', True))
946
+ mbx = bool(vis.get('mbx', False))
947
+ mtx = bool(vis.get('mtx', False))
948
+ mly = bool(vis.get('mly', False))
949
+ mry = bool(vis.get('mry', False))
950
+
951
+ if True: # Always apply
952
+ ax.tick_params(axis='x', bottom=bx, labelbottom=bx, top=tx, labeltop=tx)
953
+ ax.tick_params(axis='y', left=ly, labelleft=ly)
954
+ ax2.tick_params(axis='y', right=ry, labelright=ry)
955
+ try:
956
+ ax.xaxis.label.set_visible(bool(wasd.get('bottom', {}).get('title', True)) if wasd else bx)
957
+ ax.yaxis.label.set_visible(bool(wasd.get('left', {}).get('title', True)) if wasd else ly)
958
+ ax2.yaxis.label.set_visible(bool(wasd.get('right', {}).get('title', True)) if wasd else ry)
959
+ except Exception:
960
+ pass
961
+ # Minor ticks
962
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator, NullLocator
963
+ if mbx or mtx:
964
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
965
+ ax.xaxis.set_minor_formatter(NullFormatter())
966
+ ax.tick_params(axis='x', which='minor', bottom=mbx, top=mtx, labelbottom=False, labeltop=False)
967
+ else:
968
+ # Clear minor locator if no minor ticks are enabled
969
+ ax.xaxis.set_minor_locator(NullLocator())
970
+ ax.xaxis.set_minor_formatter(NullFormatter())
971
+ ax.tick_params(axis='x', which='minor', bottom=False, top=False, labelbottom=False, labeltop=False)
972
+ if mly:
973
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
974
+ ax.yaxis.set_minor_formatter(NullFormatter())
975
+ ax.tick_params(axis='y', which='minor', left=True, labelleft=False)
976
+ else:
977
+ # Clear minor locator if no minor ticks are enabled
978
+ ax.yaxis.set_minor_locator(NullLocator())
979
+ ax.yaxis.set_minor_formatter(NullFormatter())
980
+ ax.tick_params(axis='y', which='minor', left=False, labelleft=False)
981
+ if mry:
982
+ ax2.yaxis.set_minor_locator(AutoMinorLocator())
983
+ ax2.yaxis.set_minor_formatter(NullFormatter())
984
+ ax2.tick_params(axis='y', which='minor', right=True, labelright=False)
985
+ else:
986
+ # Clear minor locator if no minor ticks are enabled
987
+ ax2.yaxis.set_minor_locator(NullLocator())
988
+ ax2.yaxis.set_minor_formatter(NullFormatter())
989
+ ax2.tick_params(axis='y', which='minor', right=False, labelright=False)
990
+
991
+ # Widths: support both version 2 (nested in 'widths') and version 1 (direct keys)
992
+ widths = tk.get('widths', tk) # Try nested first, fall back to tk itself
993
+ if widths.get('x_major') is not None:
994
+ ax.tick_params(axis='x', which='major', width=widths['x_major'])
995
+ if widths.get('x_minor') is not None:
996
+ ax.tick_params(axis='x', which='minor', width=widths['x_minor'])
997
+ if widths.get('ly_major') is not None:
998
+ ax.tick_params(axis='y', which='major', width=widths['ly_major'])
999
+ if widths.get('ly_minor') is not None:
1000
+ ax.tick_params(axis='y', which='minor', width=widths['ly_minor'])
1001
+ if widths.get('ry_major') is not None:
1002
+ ax2.tick_params(axis='y', which='major', width=widths['ry_major'])
1003
+ if widths.get('ry_minor') is not None:
1004
+ ax2.tick_params(axis='y', which='minor', width=widths['ry_minor'])
1005
+
1006
+ # Lengths: apply to both axes
1007
+ lengths = tk.get('lengths', {})
1008
+ if lengths.get('major') is not None:
1009
+ ax.tick_params(axis='both', which='major', length=lengths['major'])
1010
+ ax2.tick_params(axis='both', which='major', length=lengths['major'])
1011
+ if lengths.get('minor') is not None:
1012
+ ax.tick_params(axis='both', which='minor', length=lengths['minor'])
1013
+ ax2.tick_params(axis='both', which='minor', length=lengths['minor'])
1014
+ if lengths:
1015
+ fig._tick_lengths = dict(lengths)
1016
+
1017
+ # Apply tick direction
1018
+ tick_direction = tk.get('direction', 'out')
1019
+ if tick_direction:
1020
+ setattr(fig, '_tick_direction', tick_direction)
1021
+ ax.tick_params(axis='both', which='both', direction=tick_direction)
1022
+ ax2.tick_params(axis='both', which='both', direction=tick_direction)
1023
+ except Exception:
1024
+ pass
1025
+ try:
1026
+ sp = cfg.get('spines', {})
1027
+ for name, spec in sp.items():
1028
+ if name in ('bottom','top','left') and name in ax.spines:
1029
+ spn = ax.spines.get(name)
1030
+ if spn is None:
1031
+ continue
1032
+ if spec.get('linewidth') is not None:
1033
+ try:
1034
+ spn.set_linewidth(float(spec['linewidth']))
1035
+ except Exception:
1036
+ pass
1037
+ if spec.get('visible') is not None:
1038
+ try:
1039
+ spn.set_visible(bool(spec['visible']))
1040
+ except Exception:
1041
+ pass
1042
+ if spec.get('color') is not None:
1043
+ _set_spine_color(name, spec['color'])
1044
+ if name == 'right' and ax2.spines.get('right') is not None:
1045
+ spn = ax2.spines.get('right')
1046
+ if spec.get('linewidth') is not None:
1047
+ try:
1048
+ spn.set_linewidth(float(spec['linewidth']))
1049
+ except Exception:
1050
+ pass
1051
+ if spec.get('visible') is not None:
1052
+ try:
1053
+ spn.set_visible(bool(spec['visible']))
1054
+ except Exception:
1055
+ pass
1056
+ if spec.get('color') is not None:
1057
+ _set_spine_color('right', spec['color'])
1058
+ # Restore spine colors from stored dict
1059
+ spine_colors = cfg.get('spine_colors', {})
1060
+ if spine_colors:
1061
+ for spine_name, color in spine_colors.items():
1062
+ _set_spine_color(spine_name, color)
1063
+ # Restore auto setting
1064
+ spine_auto = cfg.get('spine_colors_auto', False)
1065
+ if spine_auto is not None:
1066
+ fig._cpc_spine_auto = bool(spine_auto)
1067
+ # If auto is enabled, apply colors immediately
1068
+ if fig._cpc_spine_auto and not (file_data and len(file_data) > 1):
1069
+ try:
1070
+ charge_col = _color_of(sc_charge)
1071
+ eff_col = _color_of(sc_eff)
1072
+ _set_spine_color('left', charge_col)
1073
+ _set_spine_color('right', eff_col)
1074
+ except Exception:
1075
+ pass
1076
+ except Exception:
1077
+ pass
1078
+ # Restore labelpads (preserve current if not in config)
1079
+ try:
1080
+ pads = cfg.get('labelpads', {})
1081
+ if pads:
1082
+ if pads.get('x') is not None:
1083
+ ax.xaxis.labelpad = pads['x']
1084
+ elif saved_xlabelpad is not None:
1085
+ ax.xaxis.labelpad = saved_xlabelpad
1086
+ if pads.get('ly') is not None:
1087
+ ax.yaxis.labelpad = pads['ly']
1088
+ elif saved_ylabelpad is not None:
1089
+ ax.yaxis.labelpad = saved_ylabelpad
1090
+ if pads.get('ry') is not None and ax2 is not None:
1091
+ ax2.yaxis.labelpad = pads['ry']
1092
+ elif saved_rylabelpad is not None and ax2 is not None:
1093
+ ax2.yaxis.labelpad = saved_rylabelpad
1094
+ else:
1095
+ # No labelpads in config, preserve current values
1096
+ if saved_xlabelpad is not None:
1097
+ ax.xaxis.labelpad = saved_xlabelpad
1098
+ if saved_ylabelpad is not None:
1099
+ ax.yaxis.labelpad = saved_ylabelpad
1100
+ if saved_rylabelpad is not None and ax2 is not None:
1101
+ ax2.yaxis.labelpad = saved_rylabelpad
1102
+ except Exception:
1103
+ pass
1104
+ # Grid state
1105
+ try:
1106
+ grid_enabled = cfg.get('grid', False)
1107
+ if grid_enabled:
1108
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1109
+ else:
1110
+ ax.grid(False)
1111
+ except Exception:
1112
+ pass
1113
+ # Title offsets - all four titles
1114
+ try:
1115
+ offsets = cfg.get('title_offsets', {})
1116
+ # Support both old format (top/right) and new format (top_y/top_x/bottom_y/left_x/right_x/right_y)
1117
+ try:
1118
+ if 'top_y' in offsets:
1119
+ ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top_y', 0.0) or 0.0)
1120
+ else:
1121
+ # Backward compatibility: old format used 'top' for y-offset
1122
+ ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top', 0.0) or 0.0)
1123
+ except Exception:
1124
+ ax._top_xlabel_manual_offset_y_pts = 0.0
1125
+ try:
1126
+ ax._top_xlabel_manual_offset_x_pts = float(offsets.get('top_x', 0.0) or 0.0)
1127
+ except Exception:
1128
+ ax._top_xlabel_manual_offset_x_pts = 0.0
1129
+ try:
1130
+ ax._bottom_xlabel_manual_offset_y_pts = float(offsets.get('bottom_y', 0.0) or 0.0)
1131
+ except Exception:
1132
+ ax._bottom_xlabel_manual_offset_y_pts = 0.0
1133
+ try:
1134
+ ax._left_ylabel_manual_offset_x_pts = float(offsets.get('left_x', 0.0) or 0.0)
1135
+ except Exception:
1136
+ ax._left_ylabel_manual_offset_x_pts = 0.0
1137
+ try:
1138
+ if 'right_x' in offsets:
1139
+ ax2._right_ylabel_manual_offset_x_pts = float(offsets.get('right_x', 0.0) or 0.0)
1140
+ else:
1141
+ # Backward compatibility: old format used 'right' for x-offset
1142
+ ax2._right_ylabel_manual_offset_x_pts = float(offsets.get('right', 0.0) or 0.0)
1143
+ except Exception:
1144
+ ax2._right_ylabel_manual_offset_x_pts = 0.0
1145
+ try:
1146
+ ax2._right_ylabel_manual_offset_y_pts = float(offsets.get('right_y', 0.0) or 0.0)
1147
+ except Exception:
1148
+ ax2._right_ylabel_manual_offset_y_pts = 0.0
1149
+ # Reposition titles to apply offsets
1150
+ _ui_position_top_xlabel(ax, fig, tick_state)
1151
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
1152
+ _ui_position_left_ylabel(ax, fig, tick_state)
1153
+ _ui_position_right_ylabel(ax2, fig, tick_state)
1154
+ except Exception:
1155
+ pass
1156
+ # Restore legend labels
1157
+ try:
1158
+ if is_multi_file and file_data:
1159
+ multi_files = cfg.get('multi_files', [])
1160
+ if multi_files and len(multi_files) == len(file_data):
1161
+ for i, f_info in enumerate(multi_files):
1162
+ if i < len(file_data):
1163
+ f = file_data[i]
1164
+ # Restore colors FIRST (before labels)
1165
+ if 'charge_color' in f_info and f.get('sc_charge'):
1166
+ try:
1167
+ col = f_info['charge_color']
1168
+ f['sc_charge'].set_color(col)
1169
+ f['color'] = col
1170
+ # Force update of facecolors for scatter plots
1171
+ if hasattr(f['sc_charge'], 'set_facecolors'):
1172
+ from matplotlib.colors import to_rgba
1173
+ rgba = to_rgba(col)
1174
+ f['sc_charge'].set_facecolors(rgba)
1175
+ except Exception:
1176
+ pass
1177
+ if 'discharge_color' in f_info and f.get('sc_discharge'):
1178
+ try:
1179
+ col = f_info['discharge_color']
1180
+ f['sc_discharge'].set_color(col)
1181
+ # Force update of facecolors for scatter plots
1182
+ if hasattr(f['sc_discharge'], 'set_facecolors'):
1183
+ from matplotlib.colors import to_rgba
1184
+ rgba = to_rgba(col)
1185
+ f['sc_discharge'].set_facecolors(rgba)
1186
+ except Exception:
1187
+ pass
1188
+ if 'efficiency_color' in f_info and f.get('sc_eff'):
1189
+ try:
1190
+ col = f_info['efficiency_color']
1191
+ f['sc_eff'].set_color(col)
1192
+ f['eff_color'] = col
1193
+ # Force update of facecolors for scatter plots
1194
+ if hasattr(f['sc_eff'], 'set_facecolors'):
1195
+ from matplotlib.colors import to_rgba
1196
+ rgba = to_rgba(col)
1197
+ f['sc_eff'].set_facecolors(rgba)
1198
+ except Exception:
1199
+ pass
1200
+ # Restore legend labels
1201
+ if 'charge_label' in f_info and f.get('sc_charge'):
1202
+ try:
1203
+ f['sc_charge'].set_label(f_info['charge_label'])
1204
+ except Exception:
1205
+ pass
1206
+ if 'discharge_label' in f_info and f.get('sc_discharge'):
1207
+ try:
1208
+ f['sc_discharge'].set_label(f_info['discharge_label'])
1209
+ except Exception:
1210
+ pass
1211
+ if 'efficiency_label' in f_info and f.get('sc_eff'):
1212
+ try:
1213
+ f['sc_eff'].set_label(f_info['efficiency_label'])
1214
+ except Exception:
1215
+ pass
1216
+ # Update filename if present
1217
+ if 'filename' in f_info:
1218
+ f['filename'] = f_info['filename']
1219
+ else:
1220
+ # Single file mode: restore legend labels
1221
+ s = cfg.get('series', {})
1222
+ ch = s.get('charge', {})
1223
+ dh = s.get('discharge', {})
1224
+ ef = s.get('efficiency', {})
1225
+ if 'label' in ch and hasattr(sc_charge, 'set_label'):
1226
+ try:
1227
+ sc_charge.set_label(ch['label'])
1228
+ except Exception:
1229
+ pass
1230
+ if 'label' in dh and hasattr(sc_discharge, 'set_label'):
1231
+ try:
1232
+ sc_discharge.set_label(dh['label'])
1233
+ except Exception:
1234
+ pass
1235
+ if 'label' in ef and hasattr(sc_eff, 'set_label'):
1236
+ try:
1237
+ sc_eff.set_label(ef['label'])
1238
+ except Exception:
1239
+ pass
1240
+ # Rebuild legend after restoring labels
1241
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1242
+ except Exception:
1243
+ pass
1244
+ try:
1245
+ fig.canvas.draw_idle()
1246
+ except Exception:
1247
+ pass
1248
+
1249
+
1250
+ def _format_file_timestamp(filepath: str) -> str:
1251
+ """Format file modification time for display.
1252
+
1253
+ Args:
1254
+ filepath: Full path to the file
1255
+
1256
+ Returns:
1257
+ Formatted timestamp string (e.g., "2024-01-15 14:30") or empty string if error
1258
+ """
1259
+ try:
1260
+ mtime = os.path.getmtime(filepath)
1261
+ # Format as YYYY-MM-DD HH:MM
1262
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
1263
+ except Exception:
1264
+ return ""
1265
+
1266
+
1267
+ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=None):
1268
+ """
1269
+ Interactive menu for Capacity-Per-Cycle (CPC) plots.
1270
+
1271
+ HOW CPC INTERACTIVE MENU WORKS:
1272
+ ------------------------------
1273
+ This function provides an interactive command-line menu for customizing CPC plots.
1274
+ CPC plots show battery capacity and efficiency over multiple cycles.
1275
+
1276
+ PLOT STRUCTURE:
1277
+ --------------
1278
+ CPC plots have two Y-axes (twin axes):
1279
+ - Left Y-axis: Capacity (mAh/g or mAh) - shows charge and discharge capacity
1280
+ - Right Y-axis: Efficiency (%) - shows coulombic efficiency
1281
+
1282
+ X-axis: Cycle number (1, 2, 3, ...)
1283
+
1284
+ Each cycle is represented by scatter points:
1285
+ - Charge capacity point (left axis)
1286
+ - Discharge capacity point (left axis)
1287
+ - Efficiency point (right axis)
1288
+
1289
+ MULTI-FILE MODE:
1290
+ --------------
1291
+ CPC mode supports plotting multiple files simultaneously:
1292
+ - Each file gets its own set of scatter points (charge, discharge, efficiency)
1293
+ - Each file can have different colors
1294
+ - Files can be shown/hidden individually
1295
+ - You can switch between files to edit their properties
1296
+
1297
+ MENU COMMANDS:
1298
+ -------------
1299
+ The menu is organized into three categories:
1300
+
1301
+ **Styles** (visual appearance):
1302
+ - f: font (size and family)
1303
+ - l: line (width and style)
1304
+ - m: marker sizes
1305
+ - c: colors (for charge, discharge, efficiency)
1306
+ - k: spine colors (plot border colors)
1307
+ - ry: show/hide efficiency (right Y-axis)
1308
+ - t: toggle axes (show/hide tick labels)
1309
+ - h: legend (show/hide)
1310
+ - g: size (figure and axes size)
1311
+ - v: show/hide files (multi-file mode)
1312
+
1313
+ **Geometries** (axis ranges and labels):
1314
+ - r: rename titles (axis labels)
1315
+ - x: x range (cycle number range)
1316
+ - y: y ranges (capacity and efficiency ranges)
1317
+
1318
+ **Options** (file operations):
1319
+ - p: print/export style/geometry (save .bpcfg file)
1320
+ - i: import style/geometry (load .bpcfg file)
1321
+ - e: export figure (save plot as image)
1322
+ - s: save project (save session as .pkl)
1323
+ - b: undo (revert last change)
1324
+ - q: quit (exit menu)
1325
+
1326
+ Args:
1327
+ fig: Matplotlib figure object
1328
+ ax: Primary axes (left Y-axis, shows capacity)
1329
+ ax2: Twin axes (right Y-axis, shows efficiency)
1330
+ sc_charge: Scatter plot artist for charge capacity (primary file)
1331
+ sc_discharge: Scatter plot artist for discharge capacity (primary file)
1332
+ sc_eff: Scatter plot artist for efficiency (primary file)
1333
+ file_data: Optional list of dictionaries, one per file:
1334
+ - 'filename': File name (for display)
1335
+ - 'sc_charge': Scatter artist for charge capacity
1336
+ - 'sc_discharge': Scatter artist for discharge capacity
1337
+ - 'sc_eff': Scatter artist for efficiency
1338
+ - 'visible': Whether file is currently visible
1339
+ - 'filepath': Path to source file (optional)
1340
+ """
1341
+ # ====================================================================
1342
+ # MULTI-FILE MODE SETUP
1343
+ # ====================================================================
1344
+ # CPC mode can handle multiple files simultaneously. Each file gets its
1345
+ # own set of scatter points (charge, discharge, efficiency) with its
1346
+ # own colors. This allows comparing multiple battery cells or conditions.
1347
+ #
1348
+ # If file_data is provided, we're in multi-file mode.
1349
+ # If not provided, we create a single-file structure for backward compatibility.
1350
+ # ====================================================================
1351
+ is_multi_file = file_data is not None and len(file_data) > 1
1352
+
1353
+ if file_data is None:
1354
+ # Backward compatibility: create file_data structure from single file
1355
+ # This allows the function to work with old code that passes individual artists
1356
+ # Try to get filename from label if available
1357
+ filename = 'Data'
1358
+ try:
1359
+ if hasattr(sc_charge, 'get_label') and sc_charge.get_label():
1360
+ label = sc_charge.get_label()
1361
+ # Extract filename from label like "filename (Chg)" or use label as-is
1362
+ if ' (Chg)' in label:
1363
+ filename = label.replace(' (Chg)', '')
1364
+ elif ' (Dch)' in label:
1365
+ filename = label.replace(' (Dch)', '')
1366
+ elif label and label != 'Charge capacity':
1367
+ filename = label
1368
+ except Exception:
1369
+ pass
1370
+ file_data = [{
1371
+ 'filename': filename,
1372
+ 'sc_charge': sc_charge, # Charge capacity scatter artist
1373
+ 'sc_discharge': sc_discharge, # Discharge capacity scatter artist
1374
+ 'sc_eff': sc_eff, # Efficiency scatter artist
1375
+ 'visible': True # File is visible by default
1376
+ }]
1377
+
1378
+ # Track which file is currently selected for editing (in multi-file mode)
1379
+ current_file_idx = 0 # Index of currently selected file (0 = first file)
1380
+
1381
+ # Collect file paths for session saving (if available)
1382
+ file_paths = _collect_file_paths(file_data)
1383
+
1384
+ # ====================================================================
1385
+ # TICK STATE MANAGEMENT
1386
+ # ====================================================================
1387
+ # CPC plots have two axes (primary + twin), so we need to track tick
1388
+ # visibility for both. The tick_state dictionary tracks:
1389
+ #
1390
+ # Primary axes (ax):
1391
+ # - bx: bottom x-axis ticks and labels
1392
+ # - tx: top x-axis ticks and labels
1393
+ # - ly: left y-axis ticks and labels (capacity)
1394
+ # - mbx: minor bottom x-axis ticks
1395
+ # - mtx: minor top x-axis ticks
1396
+ # - mly: minor left y-axis ticks
1397
+ #
1398
+ # Twin axes (ax2):
1399
+ # - ry: right y-axis ticks and labels (efficiency)
1400
+ # - mry: minor right y-axis ticks
1401
+ #
1402
+ # Users can toggle these with 't' command to customize plot appearance.
1403
+ # ====================================================================
1404
+ tick_state = {
1405
+ 'bx': True, # bottom x-axis (cycle numbers) - shown by default
1406
+ 'tx': False, # top x-axis - hidden by default
1407
+ 'ly': True, # left y-axis (capacity) - shown by default
1408
+ 'ry': True, # right y-axis (efficiency) - shown by default
1409
+ 'mbx': False, # minor bottom x-axis ticks - hidden by default
1410
+ 'mtx': False, # minor top x-axis ticks - hidden by default
1411
+ 'mly': False, # minor left y-axis ticks - hidden by default
1412
+ 'mry': False, # minor right y-axis ticks - hidden by default
1413
+ }
1414
+ try:
1415
+ saved_wasd = getattr(fig, '_cpc_wasd_state', None)
1416
+ if isinstance(saved_wasd, dict) and saved_wasd:
1417
+ tick_state['bx'] = bool(saved_wasd.get('bottom', {}).get('labels', tick_state['bx']))
1418
+ tick_state['tx'] = bool(saved_wasd.get('top', {}).get('labels', tick_state['tx']))
1419
+ tick_state['ly'] = bool(saved_wasd.get('left', {}).get('labels', tick_state['ly']))
1420
+ tick_state['ry'] = bool(saved_wasd.get('right', {}).get('labels', tick_state['ry']))
1421
+ tick_state['mbx'] = bool(saved_wasd.get('bottom', {}).get('minor', tick_state['mbx']))
1422
+ tick_state['mtx'] = bool(saved_wasd.get('top', {}).get('minor', tick_state['mtx']))
1423
+ tick_state['mly'] = bool(saved_wasd.get('left', {}).get('minor', tick_state['mly']))
1424
+ tick_state['mry'] = bool(saved_wasd.get('right', {}).get('minor', tick_state['mry']))
1425
+ except Exception:
1426
+ pass
1427
+
1428
+ # --- Undo stack using style snapshots ---
1429
+ state_history = [] # list of cfg dicts
1430
+
1431
+ if not hasattr(fig, '_cpc_spine_colors') or not isinstance(getattr(fig, '_cpc_spine_colors'), dict):
1432
+ fig._cpc_spine_colors = {}
1433
+
1434
+ def _set_spine_color(spine_name: str, color: str):
1435
+ if not hasattr(fig, '_cpc_spine_colors') or not isinstance(fig._cpc_spine_colors, dict):
1436
+ fig._cpc_spine_colors = {}
1437
+ fig._cpc_spine_colors[spine_name] = color
1438
+ axes_map = {
1439
+ 'top': [ax, ax2],
1440
+ 'bottom': [ax, ax2],
1441
+ 'left': [ax],
1442
+ 'right': [ax2],
1443
+ }
1444
+ target_axes = axes_map.get(spine_name, [ax, ax2])
1445
+ for curr_ax in target_axes:
1446
+ if curr_ax is None or spine_name not in curr_ax.spines:
1447
+ continue
1448
+ sp = curr_ax.spines[spine_name]
1449
+ try:
1450
+ sp.set_edgecolor(color)
1451
+ except Exception:
1452
+ pass
1453
+ try:
1454
+ if spine_name in ('top', 'bottom'):
1455
+ curr_ax.tick_params(axis='x', which='both', colors=color)
1456
+ curr_ax.xaxis.label.set_color(color)
1457
+ elif spine_name == 'left':
1458
+ curr_ax.tick_params(axis='y', which='both', colors=color)
1459
+ curr_ax.yaxis.label.set_color(color)
1460
+ elif spine_name == 'right':
1461
+ curr_ax.tick_params(axis='y', which='both', colors=color)
1462
+ curr_ax.yaxis.label.set_color(color)
1463
+ except Exception:
1464
+ pass
1465
+
1466
+ def push_state(note: str = ""):
1467
+ try:
1468
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
1469
+ snap['__note__'] = note
1470
+ # Also persist current tick_state explicitly
1471
+ snap.setdefault('ticks', {}).setdefault('visibility', dict(tick_state))
1472
+ state_history.append(snap)
1473
+ if len(state_history) > 40:
1474
+ state_history.pop(0)
1475
+ except Exception:
1476
+ pass
1477
+
1478
+ def restore_state():
1479
+ if not state_history:
1480
+ print("No undo history.")
1481
+ return
1482
+ cfg = state_history.pop()
1483
+ try:
1484
+ _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg, file_data)
1485
+ # Restore local tick_state from cfg
1486
+ vis = (cfg.get('ticks') or {}).get('visibility') or {}
1487
+ for k, v in vis.items():
1488
+ if k in tick_state:
1489
+ tick_state[k] = bool(v)
1490
+ _update_ticks()
1491
+ # Re-apply legend text colors after state restore (undo)
1492
+ try:
1493
+ leg = ax.get_legend()
1494
+ if leg is not None:
1495
+ handles = list(getattr(leg, "legendHandles", []))
1496
+ for h, txt in zip(handles, leg.get_texts()):
1497
+ col = _color_of(h)
1498
+ if col is None and hasattr(h, 'get_edgecolor'):
1499
+ col = h.get_edgecolor()
1500
+ if isinstance(col, (list, tuple)) and len(col) and not isinstance(col, str):
1501
+ col = col[0]
1502
+ try:
1503
+ import numpy as _np
1504
+ if hasattr(col, "__len__") and not isinstance(col, str):
1505
+ col = tuple(_np.array(col).ravel().tolist())
1506
+ except Exception:
1507
+ pass
1508
+ if col is not None:
1509
+ txt.set_color(col)
1510
+ except Exception:
1511
+ pass
1512
+ try:
1513
+ fig.canvas.draw()
1514
+ except Exception:
1515
+ fig.canvas.draw_idle()
1516
+ print("Undo: restored previous state.")
1517
+ except Exception as e:
1518
+ print(f"Undo failed: {e}")
1519
+
1520
+ def _update_ticks():
1521
+ try:
1522
+ # Apply shared visibility to primary ax; then adjust twin for right side
1523
+ _ui_update_tick_visibility(ax, tick_state)
1524
+ # Ensure left axis ticks/labels don't appear on right axis
1525
+ ax.tick_params(axis='y', right=False, labelright=False)
1526
+ # Right axis tick params follow r_* keys
1527
+ ax2.tick_params(axis='y',
1528
+ right=tick_state.get('r_ticks', tick_state.get('ry', False)),
1529
+ labelright=tick_state.get('r_labels', tick_state.get('ry', False)))
1530
+ # Minor right-y consistency
1531
+ if tick_state.get('mry'):
1532
+ ax2.yaxis.set_minor_locator(AutoMinorLocator()); ax2.yaxis.set_minor_formatter(NullFormatter())
1533
+ ax2.tick_params(axis='y', which='minor', right=True, labelright=False)
1534
+ else:
1535
+ ax2.tick_params(axis='y', which='minor', right=False, labelright=False)
1536
+ # Position label spacings (bottom/left) for consistency
1537
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
1538
+ _ui_position_left_ylabel(ax, fig, tick_state)
1539
+ try:
1540
+ for spine_name, color in getattr(fig, '_cpc_spine_colors', {}).items():
1541
+ _set_spine_color(spine_name, color)
1542
+ except Exception:
1543
+ pass
1544
+ fig.canvas.draw_idle()
1545
+ except Exception:
1546
+ pass
1547
+
1548
+ def _toggle_spine(code: str):
1549
+ # Map bl/tl/ll to ax; rl to ax2
1550
+ try:
1551
+ if code == 'bl':
1552
+ sp = ax.spines.get('bottom'); sp.set_visible(not sp.get_visible())
1553
+ elif code == 'tl':
1554
+ sp = ax.spines.get('top'); sp.set_visible(not sp.get_visible())
1555
+ elif code == 'll':
1556
+ sp = ax.spines.get('left'); sp.set_visible(not sp.get_visible())
1557
+ elif code == 'rl':
1558
+ sp = ax2.spines.get('right'); sp.set_visible(not sp.get_visible())
1559
+ fig.canvas.draw_idle()
1560
+ except Exception:
1561
+ pass
1562
+
1563
+ def _sanitize_legend_offset(xy: Optional[tuple]) -> Optional[tuple]:
1564
+ if xy is None or not isinstance(xy, tuple) or len(xy) != 2:
1565
+ return None
1566
+ x_in, y_in = xy
1567
+ try:
1568
+ x_val = float(x_in)
1569
+ y_val = float(y_in)
1570
+ except Exception:
1571
+ return None
1572
+ fw, fh = fig.get_size_inches()
1573
+ if fw <= 0 or fh <= 0:
1574
+ return None
1575
+ max_offset = max(fw, fh) * 2.0
1576
+ if abs(x_val) > max_offset or abs(y_val) > max_offset:
1577
+ return None
1578
+ return (x_val, y_val)
1579
+
1580
+ def _apply_legend_position():
1581
+ """Reapply legend position using stored inches offset relative to canvas center."""
1582
+ try:
1583
+ xy_in = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
1584
+ if xy_in is None:
1585
+ return
1586
+ # Compute figure-fraction anchor from inches
1587
+ fw, fh = fig.get_size_inches()
1588
+ if fw <= 0 or fh <= 0:
1589
+ return
1590
+ fx = 0.5 + float(xy_in[0]) / float(fw)
1591
+ fy = 0.5 + float(xy_in[1]) / float(fh)
1592
+ # Use current visible handles/labels
1593
+ H, L = _visible_handles_labels(ax, ax2)
1594
+ if H:
1595
+ _legend_no_frame(
1596
+ ax,
1597
+ H,
1598
+ L,
1599
+ loc='center',
1600
+ bbox_to_anchor=(fx, fy),
1601
+ bbox_transform=fig.transFigure,
1602
+ borderaxespad=1.0,
1603
+ title=_get_legend_title(fig),
1604
+ )
1605
+ except Exception:
1606
+ pass
1607
+
1608
+ # Ensure resize re-applies legend position in inches
1609
+ try:
1610
+ if not hasattr(fig, '_cpc_legpos_cid') or getattr(fig, '_cpc_legpos_cid') is None:
1611
+ def _on_resize(event):
1612
+ _apply_legend_position()
1613
+ try:
1614
+ fig.canvas.draw_idle()
1615
+ except Exception:
1616
+ pass
1617
+ fig._cpc_legpos_cid = fig.canvas.mpl_connect('resize_event', _on_resize)
1618
+ except Exception:
1619
+ pass
1620
+
1621
+ _print_menu()
1622
+
1623
+ while True:
1624
+ try:
1625
+ # Update current file's scatter artists for commands that need them
1626
+ sc_charge, sc_discharge, sc_eff = _get_current_file_artists(file_data, current_file_idx)
1627
+
1628
+ key = _safe_input("Press a key: ").strip().lower()
1629
+ except (KeyboardInterrupt, EOFError):
1630
+ print("\n\nExiting interactive menu...")
1631
+ break
1632
+ if not key:
1633
+ continue
1634
+
1635
+ # File visibility toggle command (v)
1636
+ if key == 'v':
1637
+ try:
1638
+ if is_multi_file:
1639
+ _print_file_list(file_data, current_file_idx)
1640
+ choice = _safe_input(f"Toggle visibility for file (1-{len(file_data)}), 'a' for all, or q=cancel: ").strip()
1641
+ if choice.lower() == 'q':
1642
+ _print_menu()
1643
+ _print_file_list(file_data, current_file_idx)
1644
+ continue
1645
+
1646
+ push_state("visibility")
1647
+ if choice.lower() == 'a':
1648
+ # Toggle all
1649
+ any_visible = any(f.get('visible', True) for f in file_data)
1650
+ new_state = not any_visible
1651
+ for f in file_data:
1652
+ f['visible'] = new_state
1653
+ f['sc_charge'].set_visible(new_state)
1654
+ f['sc_discharge'].set_visible(new_state)
1655
+ f['sc_eff'].set_visible(new_state)
1656
+ else:
1657
+ idx = int(choice) - 1
1658
+ if 0 <= idx < len(file_data):
1659
+ f = file_data[idx]
1660
+ new_vis = not f.get('visible', True)
1661
+ f['visible'] = new_vis
1662
+ f['sc_charge'].set_visible(new_vis)
1663
+ f['sc_discharge'].set_visible(new_vis)
1664
+ f['sc_eff'].set_visible(new_vis)
1665
+ else:
1666
+ print("Invalid file number.")
1667
+ else:
1668
+ # Single file mode: toggle efficiency
1669
+ push_state("visibility-eff")
1670
+ # Capture current legend position BEFORE toggling visibility
1671
+ try:
1672
+ if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
1673
+ leg0 = ax.get_legend()
1674
+ if leg0 is not None and leg0.get_visible():
1675
+ try:
1676
+ # Ensure renderer exists
1677
+ try:
1678
+ renderer = fig.canvas.get_renderer()
1679
+ except Exception:
1680
+ fig.canvas.draw()
1681
+ renderer = fig.canvas.get_renderer()
1682
+ bb = leg0.get_window_extent(renderer=renderer)
1683
+ cx = 0.5 * (bb.x0 + bb.x1)
1684
+ cy = 0.5 * (bb.y0 + bb.y1)
1685
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
1686
+ fw, fh = fig.get_size_inches()
1687
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
1688
+ offset = _sanitize_legend_offset(offset)
1689
+ if offset is not None:
1690
+ fig._cpc_legend_xy_in = offset
1691
+ except Exception:
1692
+ pass
1693
+ except Exception:
1694
+ pass
1695
+ vis = sc_eff.get_visible()
1696
+ sc_eff.set_visible(not vis)
1697
+ try:
1698
+ ax2.yaxis.label.set_visible(not vis)
1699
+ except Exception:
1700
+ pass
1701
+
1702
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1703
+ fig.canvas.draw_idle()
1704
+ except ValueError:
1705
+ print("Invalid input.")
1706
+ except Exception as e:
1707
+ print(f"Visibility toggle failed: {e}")
1708
+ _print_menu()
1709
+ if is_multi_file:
1710
+ _print_file_list(file_data, current_file_idx)
1711
+ continue
1712
+
1713
+ if key == 'q':
1714
+ try:
1715
+ confirm = _safe_input(_colorize_prompt("Quit CPC interactive? Remember to save! Quit now? (y/n): ")).strip().lower()
1716
+ except Exception:
1717
+ confirm = 'y'
1718
+ if confirm == 'y':
1719
+ break
1720
+ else:
1721
+ _print_menu(); continue
1722
+ elif key == 'b':
1723
+ restore_state()
1724
+ _print_menu(); continue
1725
+ elif key == 'c':
1726
+ # Colors submenu: ly (left Y series) and ry (right Y efficiency), with user colors and palettes
1727
+ try:
1728
+ # Note: Individual series may use different colors, so we can't show a single "current" palette
1729
+ # Use same palettes as EC interactive
1730
+ palette_opts = ['tab10', 'Set2', 'Dark2', 'viridis', 'plasma']
1731
+ def _palette_color(name, idx=0, total=1, default_val=0.4):
1732
+ import matplotlib.cm as cm
1733
+ import matplotlib.colors as mcolors
1734
+ import numpy as _np
1735
+ # Ensure colormap is registered before use
1736
+ if not ensure_colormap(name):
1737
+ # Fallback to viridis if colormap can't be registered
1738
+ name = 'viridis'
1739
+ ensure_colormap(name)
1740
+ try:
1741
+ cmap = cm.get_cmap(name)
1742
+ except Exception:
1743
+ # Fallback if get_cmap fails
1744
+ ensure_colormap('viridis')
1745
+ cmap = cm.get_cmap('viridis')
1746
+
1747
+ # Special handling for tab10 to match hardcoded colors exactly
1748
+ if name.lower() == 'tab10':
1749
+ default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
1750
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
1751
+ return default_tab10_colors[idx % len(default_tab10_colors)]
1752
+
1753
+ # For discrete colormaps (Set2, Dark2), access colors directly
1754
+ if hasattr(cmap, 'colors') and cmap.colors is not None:
1755
+ # Discrete colormap: access colors directly by index
1756
+ colors = cmap.colors
1757
+ rgb = colors[idx % len(colors)]
1758
+ if isinstance(rgb, tuple) and len(rgb) >= 3:
1759
+ return mcolors.rgb2hex(rgb[:3])
1760
+
1761
+ # For continuous colormaps (viridis, plasma), sample evenly
1762
+ if total == 1:
1763
+ vals = [0.55]
1764
+ elif total == 2:
1765
+ vals = [0.15, 0.85]
1766
+ else:
1767
+ vals = _np.linspace(0.08, 0.88, total)
1768
+ rgb = cmap(vals[idx % len(vals)])
1769
+ return mcolors.rgb2hex(rgb[:3])
1770
+ def _resolve_color(spec, idx=0, total=1, default_cmap='tab10'):
1771
+ spec = spec.strip()
1772
+ if not spec:
1773
+ return None
1774
+ if spec.lower() == 'r':
1775
+ return _palette_color(default_cmap, idx, total, 0.4)
1776
+ # user colors: u# or plain number referencing saved list
1777
+ uc = None
1778
+ if spec.lower().startswith('u') and len(spec) > 1 and spec[1:].isdigit():
1779
+ uc = resolve_color_token(spec, fig)
1780
+ elif spec.isdigit():
1781
+ # number as palette index if within palette list
1782
+ n = int(spec)
1783
+ if 1 <= n <= len(palette_opts):
1784
+ palette_name = palette_opts[n-1]
1785
+ return _palette_color(palette_name, idx, total, 0.4)
1786
+ if uc:
1787
+ return uc
1788
+ # Check if spec is a palette name (case-insensitive)
1789
+ spec_lower = spec.lower()
1790
+ base = spec.rstrip('_r').rstrip('_R')
1791
+ base_lower = base.lower()
1792
+ # Check against palette_opts (case-insensitive)
1793
+ for pal in palette_opts:
1794
+ if spec_lower == pal.lower() or base_lower == pal.lower() or spec_lower == (pal + '_r').lower():
1795
+ return _palette_color(pal if not spec.endswith('_r') and not spec.endswith('_R') else spec, idx, total, 0.4)
1796
+ # Fall back to resolve_color_token for hex colors, named colors, etc.
1797
+ return resolve_color_token(spec, fig)
1798
+
1799
+ while True:
1800
+ print("\nColors: ly=capacity curves, ry=efficiency triangles, u=user colors, q=back")
1801
+ sub = _safe_input("Colors> ").strip().lower()
1802
+ if not sub:
1803
+ continue
1804
+ if sub == 'q':
1805
+ break
1806
+ if sub == 'u':
1807
+ manage_user_colors(fig); continue
1808
+ if sub == 'ly':
1809
+ push_state("colors-ly")
1810
+ print("\nCurrent capacity curves:")
1811
+ for i, f in enumerate(file_data, 1):
1812
+ cur = _color_of(f['sc_charge'])
1813
+ vis_mark = "●" if f.get('visible', True) else "○"
1814
+ print(f" {i}. {vis_mark} {f['filename']} {color_block(cur)} {cur}")
1815
+ uc = get_user_color_list(fig)
1816
+ if uc:
1817
+ print("\nSaved colors (refer as number or u#):")
1818
+ for i, c in enumerate(uc, 1):
1819
+ print(f" {i}: {color_block(c)} {c}")
1820
+ print("\nPalettes:")
1821
+ for idx, name in enumerate(palette_opts, 1):
1822
+ bar = palette_preview(name)
1823
+ print(f" {idx}. {name}")
1824
+ if bar:
1825
+ print(f" {bar}")
1826
+ color_input = _safe_input("Enter file+color pairs (e.g., 1:2 2:3 or 1 2 2 3) or palette/number for all, q=cancel: ").strip()
1827
+ if not color_input or color_input.lower() == 'q':
1828
+ continue
1829
+ tokens = color_input.split()
1830
+ if len(tokens) == 1:
1831
+ # Single token: apply palette to all files
1832
+ spec = tokens[0]
1833
+ for i, f in enumerate(file_data):
1834
+ charge_col = _resolve_color(spec, i, len(file_data), default_cmap='tab10')
1835
+ if not charge_col:
1836
+ continue
1837
+ discharge_col = _generate_similar_color(charge_col)
1838
+ try:
1839
+ f['sc_charge'].set_color(charge_col)
1840
+ f['sc_discharge'].set_color(discharge_col)
1841
+ f['color'] = charge_col
1842
+ # Force update of facecolors for scatter plots
1843
+ if hasattr(f['sc_charge'], 'set_facecolors'):
1844
+ from matplotlib.colors import to_rgba
1845
+ rgba = to_rgba(charge_col)
1846
+ f['sc_charge'].set_facecolors(rgba)
1847
+ if hasattr(f['sc_discharge'], 'set_facecolors'):
1848
+ from matplotlib.colors import to_rgba
1849
+ rgba = to_rgba(discharge_col)
1850
+ f['sc_discharge'].set_facecolors(rgba)
1851
+ except Exception as e:
1852
+ print(f"Error setting color: {e}")
1853
+ pass
1854
+ else:
1855
+ # Multiple tokens: parse file:color pairs
1856
+ def _apply_manual_entries(tokens):
1857
+ idx_color_pairs = []
1858
+ i = 0
1859
+ while i < len(tokens):
1860
+ tok = tokens[i]
1861
+ if ':' in tok:
1862
+ idx_str, color = tok.split(':', 1)
1863
+ else:
1864
+ if i + 1 >= len(tokens):
1865
+ print(f"Skip incomplete entry: {tok}")
1866
+ break
1867
+ idx_str = tok
1868
+ color = tokens[i + 1]
1869
+ i += 1
1870
+ idx_color_pairs.append((idx_str, color))
1871
+ i += 1
1872
+ for idx_str, color in idx_color_pairs:
1873
+ try:
1874
+ file_idx = int(idx_str) - 1
1875
+ except ValueError:
1876
+ print(f"Bad index: {idx_str}")
1877
+ continue
1878
+ if not (0 <= file_idx < len(file_data)):
1879
+ print(f"Index out of range: {idx_str}")
1880
+ continue
1881
+ resolved = resolve_color_token(color, fig)
1882
+ charge_col = resolved if resolved else color
1883
+ if not charge_col:
1884
+ continue
1885
+ discharge_col = _generate_similar_color(charge_col)
1886
+ try:
1887
+ file_data[file_idx]['sc_charge'].set_color(charge_col)
1888
+ file_data[file_idx]['sc_discharge'].set_color(discharge_col)
1889
+ file_data[file_idx]['color'] = charge_col
1890
+ # Force update of facecolors for scatter plots
1891
+ if hasattr(file_data[file_idx]['sc_charge'], 'set_facecolors'):
1892
+ from matplotlib.colors import to_rgba
1893
+ rgba = to_rgba(charge_col)
1894
+ file_data[file_idx]['sc_charge'].set_facecolors(rgba)
1895
+ if hasattr(file_data[file_idx]['sc_discharge'], 'set_facecolors'):
1896
+ from matplotlib.colors import to_rgba
1897
+ rgba = to_rgba(discharge_col)
1898
+ file_data[file_idx]['sc_discharge'].set_facecolors(rgba)
1899
+ except Exception:
1900
+ pass
1901
+ _apply_manual_entries(tokens)
1902
+ if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1903
+ try:
1904
+ cur_col = _color_of(sc_charge)
1905
+ if cur_col:
1906
+ _set_spine_color('left', cur_col)
1907
+ except Exception:
1908
+ pass
1909
+ try:
1910
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
1911
+ except Exception:
1912
+ pass
1913
+ elif sub == 'ry':
1914
+ push_state("colors-ry")
1915
+ print("\nCurrent efficiency curves:")
1916
+ for i, f in enumerate(file_data, 1):
1917
+ cur = _color_of(f['sc_eff'])
1918
+ vis_mark = "●" if f.get('visible', True) else "○"
1919
+ print(f" {i}. {vis_mark} {f['filename']} {color_block(cur)} {cur}")
1920
+ uc = get_user_color_list(fig)
1921
+ if uc:
1922
+ print("\nSaved colors (refer as number or u#):")
1923
+ for i, c in enumerate(uc, 1):
1924
+ print(f" {i}: {color_block(c)} {c}")
1925
+ print("\nPalettes:")
1926
+ for idx, name in enumerate(palette_opts, 1):
1927
+ bar = palette_preview(name)
1928
+ print(f" {idx}. {name}")
1929
+ if bar:
1930
+ print(f" {bar}")
1931
+ color_input = _safe_input("Enter file+color pairs (e.g., 1:2 2:3 or 1 2 2 3) or palette/number for all, q=cancel: ").strip()
1932
+ if not color_input or color_input.lower() == 'q':
1933
+ continue
1934
+ tokens = color_input.split()
1935
+ if len(tokens) == 1:
1936
+ # Single token: apply palette to all files
1937
+ spec = tokens[0]
1938
+ for i, f in enumerate(file_data):
1939
+ col = _resolve_color(spec, i, len(file_data), default_cmap='viridis')
1940
+ if not col:
1941
+ continue
1942
+ try:
1943
+ f['sc_eff'].set_color(col)
1944
+ f['eff_color'] = col
1945
+ # Force update of facecolors for scatter plots
1946
+ if hasattr(f['sc_eff'], 'set_facecolors'):
1947
+ from matplotlib.colors import to_rgba
1948
+ rgba = to_rgba(col)
1949
+ f['sc_eff'].set_facecolors(rgba)
1950
+ except Exception:
1951
+ pass
1952
+ else:
1953
+ # Multiple tokens: parse file:color pairs
1954
+ def _apply_manual_entries_eff(tokens):
1955
+ idx_color_pairs = []
1956
+ i = 0
1957
+ while i < len(tokens):
1958
+ tok = tokens[i]
1959
+ if ':' in tok:
1960
+ idx_str, color = tok.split(':', 1)
1961
+ else:
1962
+ if i + 1 >= len(tokens):
1963
+ print(f"Skip incomplete entry: {tok}")
1964
+ break
1965
+ idx_str = tok
1966
+ color = tokens[i + 1]
1967
+ i += 1
1968
+ idx_color_pairs.append((idx_str, color))
1969
+ i += 1
1970
+ for idx_str, color in idx_color_pairs:
1971
+ try:
1972
+ file_idx = int(idx_str) - 1
1973
+ except ValueError:
1974
+ print(f"Bad index: {idx_str}")
1975
+ continue
1976
+ if not (0 <= file_idx < len(file_data)):
1977
+ print(f"Index out of range: {idx_str}")
1978
+ continue
1979
+ resolved = resolve_color_token(color, fig)
1980
+ col = resolved if resolved else color
1981
+ if not col:
1982
+ continue
1983
+ try:
1984
+ file_data[file_idx]['sc_eff'].set_color(col)
1985
+ file_data[file_idx]['eff_color'] = col
1986
+ # Force update of facecolors for scatter plots
1987
+ if hasattr(file_data[file_idx]['sc_eff'], 'set_facecolors'):
1988
+ from matplotlib.colors import to_rgba
1989
+ rgba = to_rgba(col)
1990
+ file_data[file_idx]['sc_eff'].set_facecolors(rgba)
1991
+ except Exception:
1992
+ pass
1993
+ _apply_manual_entries_eff(tokens)
1994
+ if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1995
+ try:
1996
+ cur_col = _color_of(sc_eff)
1997
+ if cur_col:
1998
+ _set_spine_color('right', cur_col)
1999
+ except Exception:
2000
+ pass
2001
+ try:
2002
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
2003
+ except Exception:
2004
+ pass
2005
+ else:
2006
+ print("Unknown option.")
2007
+ except Exception as e:
2008
+ print(f"Error in colors menu: {e}")
2009
+ _print_menu()
2010
+ if is_multi_file:
2011
+ _print_file_list(file_data, current_file_idx)
2012
+ continue
2013
+ elif key == 'k':
2014
+ # Spine colors (w=top, a=left, s=bottom, d=right)
2015
+ try:
2016
+ while True:
2017
+ print("\nSet spine colors (with matching tick and label colors):")
2018
+ print(_colorize_inline_commands(" w : top spine | a : left spine"))
2019
+ print(_colorize_inline_commands(" s : bottom spine | d : right spine"))
2020
+ print(_colorize_inline_commands("Example: w:red a:#4561F7 s:blue d:green"))
2021
+ # Add auto function when only one file is loaded
2022
+ if not is_multi_file:
2023
+ auto_enabled = getattr(fig, '_cpc_spine_auto', False)
2024
+ auto_status = "ON" if auto_enabled else "OFF"
2025
+ print(_colorize_inline_commands(f" a : auto (apply capacity curve color to left y-axis, efficiency to right y-axis) [{auto_status}]"))
2026
+ print("q: back to main menu")
2027
+ line = _safe_input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
2028
+ if not line or line.lower() == 'q':
2029
+ break
2030
+ # Handle auto toggle when only one file is loaded
2031
+ if not is_multi_file and line.lower() == 'a':
2032
+ auto_enabled = getattr(fig, '_cpc_spine_auto', False)
2033
+ fig._cpc_spine_auto = not auto_enabled
2034
+ new_status = "ON" if fig._cpc_spine_auto else "OFF"
2035
+ print(f"Auto mode: {new_status}")
2036
+ if fig._cpc_spine_auto:
2037
+ # Apply auto colors immediately
2038
+ push_state("color-spine-auto")
2039
+ try:
2040
+ # Get capacity curve color (charge color)
2041
+ charge_col = _color_of(sc_charge)
2042
+ # Get efficiency curve color
2043
+ eff_col = _color_of(sc_eff)
2044
+ # Apply to left and right spines
2045
+ _set_spine_color('left', charge_col)
2046
+ _set_spine_color('right', eff_col)
2047
+ print(f"Applied: left y-axis = {charge_col}, right y-axis = {eff_col}")
2048
+ fig.canvas.draw()
2049
+ except Exception as e:
2050
+ print(f"Error applying auto colors: {e}")
2051
+ continue
2052
+ push_state("color-spine")
2053
+ # Map wasd to spine names
2054
+ key_to_spine = {'w': 'top', 'a': 'left', 's': 'bottom', 'd': 'right'}
2055
+ tokens = line.split()
2056
+ for token in tokens:
2057
+ if ':' not in token:
2058
+ print(f"Skip malformed token: {token}")
2059
+ continue
2060
+ key_part, color = token.split(':', 1)
2061
+ key_part = key_part.lower()
2062
+ if key_part not in key_to_spine:
2063
+ print(f"Unknown key: {key_part} (use w/a/s/d)")
2064
+ continue
2065
+ spine_name = key_to_spine[key_part]
2066
+ resolved = resolve_color_token(color, fig)
2067
+ _set_spine_color(spine_name, resolved)
2068
+ print(f"Set {spine_name} spine to {resolved}")
2069
+ fig.canvas.draw()
2070
+ except Exception as e:
2071
+ print(f"Error in spine color menu: {e}")
2072
+ _print_menu()
2073
+ if is_multi_file:
2074
+ _print_file_list(file_data, current_file_idx)
2075
+ continue
2076
+ elif key == 'e':
2077
+ try:
2078
+ base_path = choose_save_path(file_paths, purpose="figure export")
2079
+ if not base_path:
2080
+ _print_menu()
2081
+ continue
2082
+ print(f"\nChosen path: {base_path}")
2083
+ # List existing figure files from Figures/ subdirectory
2084
+ fig_extensions = ('.svg', '.png', '.jpg', '.jpeg', '.pdf', '.eps', '.tif', '.tiff')
2085
+ file_list = list_files_in_subdirectory(fig_extensions, 'figure', base_path=base_path)
2086
+ files = [f[0] for f in file_list]
2087
+ if files:
2088
+ figures_dir = os.path.join(base_path, 'Figures')
2089
+ print(f"Existing figure files in {figures_dir}:")
2090
+ for i, (fname, fpath) in enumerate(file_list, 1):
2091
+ timestamp = _format_file_timestamp(fpath)
2092
+ if timestamp:
2093
+ print(f" {i}: {fname} ({timestamp})")
2094
+ else:
2095
+ print(f" {i}: {fname}")
2096
+
2097
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
2098
+ if last_figure_path:
2099
+ fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
2100
+ else:
2101
+ fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
2102
+ if not fname or fname.lower() == 'q':
2103
+ _print_menu(); continue
2104
+
2105
+ # Check for 'o' option
2106
+ if fname.lower() == 'o':
2107
+ if not last_figure_path:
2108
+ print("No previous export found.")
2109
+ _print_menu(); continue
2110
+ if not os.path.exists(last_figure_path):
2111
+ print(f"Previous export file not found: {last_figure_path}")
2112
+ _print_menu(); continue
2113
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
2114
+ if yn != 'y':
2115
+ _print_menu(); continue
2116
+ target = last_figure_path
2117
+ # Check if user selected a number
2118
+ elif fname.isdigit() and files:
2119
+ idx = int(fname)
2120
+ if 1 <= idx <= len(files):
2121
+ name = files[idx-1]
2122
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2123
+ if yn != 'y':
2124
+ _print_menu(); continue
2125
+ target = file_list[idx-1][1] # Full path from list
2126
+ else:
2127
+ print("Invalid number.")
2128
+ _print_menu(); continue
2129
+ else:
2130
+ root, ext = os.path.splitext(fname)
2131
+ if ext == '':
2132
+ fname = fname + '.svg'
2133
+ # Use organized path unless it's an absolute path
2134
+ if os.path.isabs(fname):
2135
+ target = fname
2136
+ else:
2137
+ target = get_organized_path(fname, 'figure', base_path=base_path)
2138
+ if os.path.exists(target):
2139
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2140
+ if yn != 'y':
2141
+ _print_menu(); continue
2142
+ if target:
2143
+ # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
2144
+ from .utils import ensure_exact_case_filename
2145
+ target = ensure_exact_case_filename(target)
2146
+
2147
+ # Save current legend position before export (savefig can change layout)
2148
+ saved_legend_pos = None
2149
+ try:
2150
+ saved_legend_pos = getattr(fig, '_cpc_legend_xy_in', None)
2151
+ except Exception:
2152
+ pass
2153
+
2154
+ # Remove numbering from legend labels before export
2155
+ original_labels = {}
2156
+ if is_multi_file:
2157
+ try:
2158
+ for i, f in enumerate(file_data, 1):
2159
+ # Store original labels
2160
+ original_labels[f['sc_charge']] = f['sc_charge'].get_label()
2161
+ original_labels[f['sc_discharge']] = f['sc_discharge'].get_label()
2162
+ original_labels[f['sc_eff']] = f['sc_eff'].get_label()
2163
+
2164
+ # Remove "N. " prefix from labels
2165
+ base_label = f['filename']
2166
+ f['sc_charge'].set_label(f'{base_label} charge')
2167
+ f['sc_discharge'].set_label(f'{base_label} discharge')
2168
+ f['sc_eff'].set_label(f'{base_label} efficiency')
2169
+
2170
+ # Rebuild legend without numbers
2171
+ _rebuild_legend(ax, ax2, file_data)
2172
+ except Exception:
2173
+ pass
2174
+
2175
+ # Export the figure
2176
+ _, _ext = os.path.splitext(target)
2177
+ if _ext.lower() == '.svg':
2178
+ # Temporarily force transparent patches so SVG background stays transparent
2179
+ try:
2180
+ _fig_fc = fig.get_facecolor()
2181
+ except Exception:
2182
+ _fig_fc = None
2183
+ try:
2184
+ _ax_fc = ax.get_facecolor()
2185
+ except Exception:
2186
+ _ax_fc = None
2187
+ try:
2188
+ _ax2_fc = ax2.get_facecolor()
2189
+ except Exception:
2190
+ _ax2_fc = None
2191
+ try:
2192
+ if getattr(fig, 'patch', None) is not None:
2193
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
2194
+ if getattr(ax, 'patch', None) is not None:
2195
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
2196
+ if getattr(ax2, 'patch', None) is not None:
2197
+ ax2.patch.set_alpha(0.0); ax2.patch.set_facecolor('none')
2198
+ except Exception:
2199
+ pass
2200
+ try:
2201
+ fig.savefig(target, bbox_inches='tight', transparent=True, facecolor='none', edgecolor='none')
2202
+ finally:
2203
+ try:
2204
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
2205
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
2206
+ except Exception:
2207
+ pass
2208
+ try:
2209
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
2210
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
2211
+ except Exception:
2212
+ pass
2213
+ try:
2214
+ if _ax2_fc is not None and getattr(ax2, 'patch', None) is not None:
2215
+ ax2.patch.set_alpha(1.0); ax2.patch.set_facecolor(_ax2_fc)
2216
+ except Exception:
2217
+ pass
2218
+ print(f"Exported figure to {target}")
2219
+ fig._last_figure_export_path = target
2220
+
2221
+ # Restore original labels and legend position
2222
+ if is_multi_file and original_labels:
2223
+ try:
2224
+ for artist, label in original_labels.items():
2225
+ artist.set_label(label)
2226
+ _rebuild_legend(ax, ax2, file_data)
2227
+ except Exception:
2228
+ pass
2229
+ # Restore legend position after savefig (which may have changed layout)
2230
+ if saved_legend_pos is not None:
2231
+ try:
2232
+ fig._cpc_legend_xy_in = saved_legend_pos
2233
+ _rebuild_legend(ax, ax2, file_data)
2234
+ fig.canvas.draw_idle()
2235
+ except Exception:
2236
+ pass
2237
+ else:
2238
+ fig.savefig(target, bbox_inches='tight')
2239
+ print(f"Exported figure to {target}")
2240
+ fig._last_figure_export_path = target
2241
+
2242
+ # Restore original labels and legend position
2243
+ if is_multi_file and original_labels:
2244
+ try:
2245
+ for artist, label in original_labels.items():
2246
+ artist.set_label(label)
2247
+ _rebuild_legend(ax, ax2, file_data)
2248
+ except Exception:
2249
+ pass
2250
+ # Restore legend position after savefig (which may have changed layout)
2251
+ if saved_legend_pos is not None:
2252
+ try:
2253
+ fig._cpc_legend_xy_in = saved_legend_pos
2254
+ _rebuild_legend(ax, ax2, file_data)
2255
+ fig.canvas.draw_idle()
2256
+ except Exception:
2257
+ pass
2258
+ except Exception as e:
2259
+ print(f"Export failed: {e}")
2260
+ _print_menu(); continue
2261
+ elif key == 's':
2262
+ # Save CPC session (.pkl) with all data and styles
2263
+ try:
2264
+ from .session import dump_cpc_session
2265
+ # Sync current tick/title visibility (including minors) into stored WASD state before save
2266
+ try:
2267
+ wasd = getattr(fig, '_cpc_wasd_state', {})
2268
+ if not isinstance(wasd, dict):
2269
+ wasd = {}
2270
+ # bottom
2271
+ w = wasd.setdefault('bottom', {})
2272
+ w['ticks'] = bool(tick_state.get('b_ticks', tick_state.get('bx', True)))
2273
+ w['labels'] = bool(tick_state.get('b_labels', tick_state.get('bx', True)))
2274
+ w['minor'] = bool(tick_state.get('mbx', False))
2275
+ w['title'] = bool(ax.xaxis.label.get_visible())
2276
+ try:
2277
+ sp = ax.spines.get('bottom')
2278
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2279
+ except Exception:
2280
+ pass
2281
+ # top
2282
+ w = wasd.setdefault('top', {})
2283
+ w['ticks'] = bool(tick_state.get('t_ticks', tick_state.get('tx', False)))
2284
+ w['labels'] = bool(tick_state.get('t_labels', tick_state.get('tx', False)))
2285
+ w['minor'] = bool(tick_state.get('mtx', False))
2286
+ w['title'] = bool(getattr(ax, '_top_xlabel_on', False))
2287
+ try:
2288
+ sp = ax.spines.get('top')
2289
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', False)
2290
+ except Exception:
2291
+ pass
2292
+ # left
2293
+ w = wasd.setdefault('left', {})
2294
+ w['ticks'] = bool(tick_state.get('l_ticks', tick_state.get('ly', True)))
2295
+ w['labels'] = bool(tick_state.get('l_labels', tick_state.get('ly', True)))
2296
+ w['minor'] = bool(tick_state.get('mly', False))
2297
+ w['title'] = bool(ax.yaxis.label.get_visible())
2298
+ try:
2299
+ sp = ax.spines.get('left')
2300
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2301
+ except Exception:
2302
+ pass
2303
+ # right
2304
+ w = wasd.setdefault('right', {})
2305
+ w['ticks'] = bool(tick_state.get('r_ticks', tick_state.get('ry', True)))
2306
+ w['labels'] = bool(tick_state.get('r_labels', tick_state.get('ry', True)))
2307
+ w['minor'] = bool(tick_state.get('mry', False))
2308
+ w['title'] = bool(ax2.yaxis.label.get_visible() if ax2 is not None else False)
2309
+ try:
2310
+ sp = ax2.spines.get('right') if ax2 is not None else None
2311
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2312
+ except Exception:
2313
+ pass
2314
+ setattr(fig, '_cpc_wasd_state', wasd)
2315
+ except Exception:
2316
+ pass
2317
+ folder = choose_save_path(file_paths, purpose="CPC session save")
2318
+ if not folder:
2319
+ _print_menu(); continue
2320
+ print(f"\nChosen path: {folder}")
2321
+ try:
2322
+ files = sorted([f for f in os.listdir(folder) if f.lower().endswith('.pkl')])
2323
+ except Exception:
2324
+ files = []
2325
+ if files:
2326
+ print("Existing .pkl files:")
2327
+ for i, f in enumerate(files, 1):
2328
+ filepath = os.path.join(folder, f)
2329
+ timestamp = _format_file_timestamp(filepath)
2330
+ if timestamp:
2331
+ print(f" {i}: {f} ({timestamp})")
2332
+ else:
2333
+ print(f" {i}: {f}")
2334
+ last_session_path = getattr(fig, '_last_session_save_path', None)
2335
+ if last_session_path:
2336
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2337
+ else:
2338
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2339
+ choice = _safe_input(prompt).strip()
2340
+ if not choice or choice.lower() == 'q':
2341
+ _print_menu(); continue
2342
+ if choice.lower() == 'o':
2343
+ # Overwrite last saved session
2344
+ if not last_session_path:
2345
+ print("No previous save found.")
2346
+ _print_menu(); continue
2347
+ if not os.path.exists(last_session_path):
2348
+ print(f"Previous save file not found: {last_session_path}")
2349
+ _print_menu(); continue
2350
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2351
+ if yn != 'y':
2352
+ _print_menu(); continue
2353
+ dump_cpc_session(last_session_path, fig=fig, ax=ax, ax2=ax2, sc_charge=sc_charge, sc_discharge=sc_discharge, sc_eff=sc_eff, file_data=file_data, skip_confirm=True)
2354
+ print(f"Overwritten session to {last_session_path}")
2355
+ _print_menu(); continue
2356
+ if choice.isdigit() and files:
2357
+ idx = int(choice)
2358
+ if 1 <= idx <= len(files):
2359
+ name = files[idx-1]
2360
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2361
+ if yn != 'y':
2362
+ _print_menu(); continue
2363
+ target = os.path.join(folder, name)
2364
+ dump_cpc_session(target, fig=fig, ax=ax, ax2=ax2, sc_charge=sc_charge, sc_discharge=sc_discharge, sc_eff=sc_eff, file_data=file_data, skip_confirm=True)
2365
+ fig._last_session_save_path = target
2366
+ _print_menu(); continue
2367
+ else:
2368
+ print("Invalid number.")
2369
+ _print_menu(); continue
2370
+ if choice.lower() != 'o':
2371
+ name = choice
2372
+ root, ext = os.path.splitext(name)
2373
+ if ext == '':
2374
+ name = name + '.pkl'
2375
+ target = name if os.path.isabs(name) else os.path.join(folder, name)
2376
+ if os.path.exists(target):
2377
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2378
+ if yn != 'y':
2379
+ _print_menu(); continue
2380
+ dump_cpc_session(target, fig=fig, ax=ax, ax2=ax2, sc_charge=sc_charge, sc_discharge=sc_discharge, sc_eff=sc_eff, file_data=file_data, skip_confirm=True)
2381
+ fig._last_session_save_path = target
2382
+ except Exception as e:
2383
+ print(f"Save failed: {e}")
2384
+ _print_menu(); continue
2385
+ elif key == 'p':
2386
+ try:
2387
+ style_menu_active = True
2388
+ while style_menu_active:
2389
+ # Print style info first
2390
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2391
+ snap['kind'] = 'cpc_style' # Default, will be updated if psg is chosen
2392
+
2393
+ print("\n--- CPC Style (Styles column only) ---")
2394
+
2395
+ # Figure size (g command)
2396
+ fig_cfg = snap.get('figure', {})
2397
+ canvas = fig_cfg.get('canvas_size')
2398
+ frame = fig_cfg.get('frame_size')
2399
+ if canvas and all(v is not None for v in canvas):
2400
+ print(f"Canvas size (inches): {canvas[0]:.3f} x {canvas[1]:.3f}")
2401
+ if frame and all(v is not None for v in frame):
2402
+ print(f"Plot frame size (inches): {frame[0]:.3f} x {frame[1]:.3f}")
2403
+
2404
+ # Font (f command)
2405
+ ft = snap.get('font', {})
2406
+ print(f"Font: family='{ft.get('family', '')}', size={ft.get('size', '')}")
2407
+
2408
+ # Line widths (l command)
2409
+ spines = snap.get('spines', {})
2410
+ if spines:
2411
+ print("Spines:")
2412
+ for name in ('bottom', 'top', 'left', 'right'):
2413
+ props = spines.get(name, {})
2414
+ lw = props.get('linewidth', '?')
2415
+ vis = props.get('visible', False)
2416
+ col = props.get('color')
2417
+ print(f" {name:<6} lw={lw} visible={vis} color={col}")
2418
+ # Spine colors (k command)
2419
+ spine_colors = snap.get('spine_colors', {})
2420
+ if spine_colors:
2421
+ print("Spine colors:")
2422
+ for name, color in spine_colors.items():
2423
+ print(f" {name}: {color}")
2424
+ spine_auto = snap.get('spine_colors_auto', False)
2425
+ if spine_auto:
2426
+ print(f"Spine colors auto: ON (capacity → left y-axis, efficiency → right y-axis)")
2427
+
2428
+ ticks = snap.get('ticks', {})
2429
+ print(f"Tick widths: x_major={ticks.get('x_major_width')}, x_minor={ticks.get('x_minor_width')}")
2430
+ print(f" ly_major={ticks.get('ly_major_width')}, ly_minor={ticks.get('ly_minor_width')}")
2431
+ print(f" ry_major={ticks.get('ry_major_width')}, ry_minor={ticks.get('ry_minor_width')}")
2432
+ tick_direction = ticks.get('direction', 'out')
2433
+ print(f"Tick direction: {tick_direction}")
2434
+
2435
+ # Grid
2436
+ grid_enabled = snap.get('grid', False)
2437
+ print(f"Grid: {'enabled' if grid_enabled else 'disabled'}")
2438
+
2439
+ # Multi-file colors (c command) - if available
2440
+ multi_files = snap.get('multi_files', [])
2441
+ if multi_files:
2442
+ print("\nMulti-file colors:")
2443
+ for i, finfo in enumerate(multi_files, 1):
2444
+ vis_mark = "●" if finfo.get('visible', True) else "○"
2445
+ fname = finfo.get('filename', 'unknown')
2446
+ ch_col = finfo.get('charge_color', 'N/A')
2447
+ dh_col = finfo.get('discharge_color', 'N/A')
2448
+ ef_col = finfo.get('efficiency_color', 'N/A')
2449
+ print(f" {i}. {vis_mark} {fname}")
2450
+ print(f" charge={ch_col}, discharge={dh_col}, efficiency={ef_col}")
2451
+
2452
+ # Marker sizes (m command) and Colors (c command) for single-file or default
2453
+ s = snap.get('series', {})
2454
+ ch = s.get('charge', {}); dh = s.get('discharge', {}); ef = s.get('efficiency', {})
2455
+ if not multi_files:
2456
+ # Only show single-file series info if not multi-file
2457
+ print(f"Charge: color={ch.get('color')}, markersize={ch.get('markersize')}, alpha={ch.get('alpha')}")
2458
+ print(f"Discharge: color={dh.get('color')}, markersize={dh.get('markersize')}, alpha={dh.get('alpha')}")
2459
+ print(f"Efficiency: color={ef.get('color')}, markersize={ef.get('markersize')}, alpha={ef.get('alpha')}, visible={ef.get('visible')}")
2460
+ else:
2461
+ # Show marker sizes (common across all files in multi-mode)
2462
+ print(f"\nMarker sizes (all files): charge={ch.get('markersize')}, discharge={dh.get('markersize')}, efficiency={ef.get('markersize')}")
2463
+ print(f"Alpha (all files): charge={ch.get('alpha')}, discharge={dh.get('alpha')}, efficiency={ef.get('alpha')}")
2464
+ print(f"Efficiency visible: {ef.get('visible')}")
2465
+
2466
+ # Legend (h command)
2467
+ leg_cfg = snap.get('legend', {})
2468
+ leg_vis = leg_cfg.get('visible', False)
2469
+ leg_pos = leg_cfg.get('position_inches')
2470
+ if leg_pos:
2471
+ print(f"Legend: visible={leg_vis}, position (inches from center)=({leg_pos[0]:.3f}, {leg_pos[1]:.3f})")
2472
+ else:
2473
+ print(f"Legend: visible={leg_vis}, position=auto")
2474
+
2475
+ # Toggle axes (t command) - Per-side matrix (20 parameters)
2476
+ def _onoff(v):
2477
+ return 'ON ' if bool(v) else 'off'
2478
+
2479
+ wasd = snap.get('wasd_state', {})
2480
+ if wasd:
2481
+ print("Per-side: spine, major, minor, labels, title")
2482
+ for side in ('bottom', 'top', 'left', 'right'):
2483
+ s = wasd.get(side, {})
2484
+ spine_val = _onoff(s.get('spine', False))
2485
+ major_val = _onoff(s.get('ticks', False))
2486
+ minor_val = _onoff(s.get('minor', False))
2487
+ labels_val = _onoff(s.get('labels', False))
2488
+ title_val = _onoff(s.get('title', False))
2489
+ print(f" {side:<6}: spine={spine_val} major={major_val} minor={minor_val} labels={labels_val} title={title_val}")
2490
+
2491
+ print("--- End Style ---\n")
2492
+
2493
+ # List available style files (.bps, .bpsg, .bpcfg) in Styles/ subdirectory
2494
+ style_file_list = list_files_in_subdirectory(('.bps', '.bpsg', '.bpcfg'), 'style')
2495
+ _bpcfg_files = [f[0] for f in style_file_list]
2496
+ if _bpcfg_files:
2497
+ print("Existing style files in Styles/ (.bps/.bpsg):")
2498
+ for _i, (fname, fpath) in enumerate(style_file_list, 1):
2499
+ timestamp = _format_file_timestamp(fpath)
2500
+ if timestamp:
2501
+ print(f" {_i}: {fname} ({timestamp})")
2502
+ else:
2503
+ print(f" {_i}: {fname}")
2504
+
2505
+ last_style_path = getattr(fig, '_last_style_export_path', None)
2506
+ if last_style_path:
2507
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
2508
+ else:
2509
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2510
+ if sub == 'q':
2511
+ break
2512
+ if sub == 'r' or sub == '':
2513
+ continue
2514
+ if sub == 'o':
2515
+ # Overwrite last exported style file
2516
+ if not last_style_path:
2517
+ print("No previous export found.")
2518
+ continue
2519
+ if not os.path.exists(last_style_path):
2520
+ print(f"Previous export file not found: {last_style_path}")
2521
+ continue
2522
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2523
+ if yn != 'y':
2524
+ continue
2525
+ # Rebuild config based on current state
2526
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2527
+ # Determine if last export was style-only or style+geometry
2528
+ try:
2529
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2530
+ old_cfg = json.load(f)
2531
+ if old_cfg.get('kind') == 'cpc_style_geom':
2532
+ snap['kind'] = 'cpc_style_geom'
2533
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2534
+ else:
2535
+ snap['kind'] = 'cpc_style'
2536
+ except Exception:
2537
+ snap['kind'] = 'cpc_style'
2538
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2539
+ json.dump(snap, f, indent=2)
2540
+ print(f"Overwritten style to {last_style_path}")
2541
+ style_menu_active = False
2542
+ break
2543
+ if sub == 'e':
2544
+ # Ask for ps or psg
2545
+ print("Export options:")
2546
+ print(" ps = style only (.bps)")
2547
+ print(" psg = style + geometry (.bpsg)")
2548
+ exp_choice = _safe_input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
2549
+ if not exp_choice or exp_choice == 'q':
2550
+ print("Style export canceled.")
2551
+ continue
2552
+
2553
+ if exp_choice == 'ps':
2554
+ # Style only
2555
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2556
+ snap['kind'] = 'cpc_style'
2557
+ default_ext = '.bps'
2558
+ elif exp_choice == 'psg':
2559
+ # Style + Geometry
2560
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2561
+ snap['kind'] = 'cpc_style_geom'
2562
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2563
+ default_ext = '.bpsg'
2564
+ geom = snap.get('geometry', {})
2565
+ print("\n--- Geometry ---")
2566
+ print(f"X-axis label: {geom.get('xlabel', '')}")
2567
+ print(f"Y-axis label (left): {geom.get('ylabel_left', '')}")
2568
+ if 'ylabel_right' in geom:
2569
+ print(f"Y-axis label (right): {geom.get('ylabel_right', '')}")
2570
+ xlim = geom.get('xlim', [])
2571
+ if xlim and len(xlim) == 2:
2572
+ print(f"X limits: {xlim[0]:.4g} to {xlim[1]:.4g}")
2573
+ ylim_l = geom.get('ylim_left', [])
2574
+ if ylim_l and len(ylim_l) == 2:
2575
+ print(f"Y limits (left): {ylim_l[0]:.4g} to {ylim_l[1]:.4g}")
2576
+ ylim_r = geom.get('ylim_right', [])
2577
+ if ylim_r and len(ylim_r) == 2:
2578
+ print(f"Y limits (right): {ylim_r[0]:.4g} to {ylim_r[1]:.4g}")
2579
+ else:
2580
+ print(f"Unknown option: {exp_choice}")
2581
+ continue
2582
+
2583
+ save_base = choose_save_path(file_paths, purpose="style export")
2584
+ if not save_base:
2585
+ print("Style export canceled.")
2586
+ continue
2587
+ print(f"\nChosen path: {save_base}")
2588
+ style_extensions = ('.bps', '.bpsg', '.bpcfg')
2589
+ file_list = list_files_in_subdirectory(style_extensions, 'style', base_path=save_base)
2590
+ files = [f[0] for f in file_list]
2591
+ if files:
2592
+ styles_dir = os.path.join(save_base, 'Styles')
2593
+ print(f"Existing {default_ext} files in {styles_dir}:")
2594
+ for i, (fname, fpath) in enumerate(file_list, 1):
2595
+ timestamp = _format_file_timestamp(fpath)
2596
+ if timestamp:
2597
+ print(f" {i}: {fname} ({timestamp})")
2598
+ else:
2599
+ print(f" {i}: {fname}")
2600
+ if last_style_path:
2601
+ choice = _safe_input("Enter new filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
2602
+ else:
2603
+ choice = _safe_input("Enter new filename or number to overwrite (q=cancel): ").strip()
2604
+ if not choice or choice.lower() == 'q':
2605
+ print("Style export canceled.")
2606
+ continue
2607
+ if choice.lower() == 'o':
2608
+ # Overwrite last exported style file
2609
+ if not last_style_path:
2610
+ print("No previous export found.")
2611
+ continue
2612
+ if not os.path.exists(last_style_path):
2613
+ print(f"Previous export file not found: {last_style_path}")
2614
+ continue
2615
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2616
+ if yn != 'y':
2617
+ continue
2618
+ # Rebuild config based on current state
2619
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2620
+ # Determine if last export was style-only or style+geometry
2621
+ try:
2622
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2623
+ old_cfg = json.load(f)
2624
+ if old_cfg.get('kind') == 'cpc_style_geom':
2625
+ snap['kind'] = 'cpc_style_geom'
2626
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2627
+ else:
2628
+ snap['kind'] = 'cpc_style'
2629
+ except Exception:
2630
+ snap['kind'] = 'cpc_style'
2631
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2632
+ json.dump(snap, f, indent=2)
2633
+ print(f"Overwritten style to {last_style_path}")
2634
+ style_menu_active = False
2635
+ break
2636
+ target = None
2637
+ if choice.isdigit() and files:
2638
+ idx = int(choice)
2639
+ if 1 <= idx <= len(files):
2640
+ name = files[idx-1]
2641
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2642
+ if yn == 'y':
2643
+ target = file_list[idx-1][1] # Full path from list
2644
+ else:
2645
+ print("Invalid number.")
2646
+ continue
2647
+ else:
2648
+ name = choice
2649
+ # Add default extension if no extension provided
2650
+ if not any(name.lower().endswith(ext) for ext in ['.bps', '.bpsg', '.bpcfg']):
2651
+ name = name + default_ext
2652
+ # Use organized path unless it's an absolute path
2653
+ if os.path.isabs(name):
2654
+ target = name
2655
+ else:
2656
+ target = get_organized_path(name, 'style', base_path=save_base)
2657
+ if os.path.exists(target):
2658
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2659
+ if yn != 'y':
2660
+ target = None
2661
+ if target:
2662
+ with open(target, 'w', encoding='utf-8') as f:
2663
+ json.dump(snap, f, indent=2)
2664
+ print(f"Exported CPC style to {target}")
2665
+ fig._last_style_export_path = target
2666
+ style_menu_active = False # Exit style submenu and return to main menu
2667
+ break
2668
+ else:
2669
+ print("Unknown choice.")
2670
+ except Exception as e:
2671
+ print(f"Error in style submenu: {e}")
2672
+ _print_menu(); continue
2673
+ elif key == 'i':
2674
+ try:
2675
+ path = choose_style_file(file_paths, purpose="style import")
2676
+ if not path:
2677
+ _print_menu(); continue
2678
+ push_state("import-style")
2679
+ with open(path, 'r', encoding='utf-8') as f:
2680
+ cfg = json.load(f)
2681
+
2682
+ # Check file type
2683
+ kind = cfg.get('kind', '')
2684
+ if kind not in ('cpc_style', 'cpc_style_geom'):
2685
+ print("Not a CPC style file."); _print_menu(); continue
2686
+
2687
+ has_geometry = (kind == 'cpc_style_geom' and 'geometry' in cfg)
2688
+
2689
+ # Apply style
2690
+ _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg, file_data)
2691
+
2692
+ # Apply geometry if present
2693
+ if has_geometry:
2694
+ try:
2695
+ geom = cfg.get('geometry', {})
2696
+ if 'xlabel' in geom and geom['xlabel']:
2697
+ ax.set_xlabel(geom['xlabel'])
2698
+ if 'ylabel_left' in geom and geom['ylabel_left']:
2699
+ ax.set_ylabel(geom['ylabel_left'])
2700
+ if ax2 is not None and 'ylabel_right' in geom and geom['ylabel_right']:
2701
+ ax2.set_ylabel(geom['ylabel_right'])
2702
+ if 'xlim' in geom and isinstance(geom['xlim'], list) and len(geom['xlim']) == 2:
2703
+ ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
2704
+ if 'ylim_left' in geom and isinstance(geom['ylim_left'], list) and len(geom['ylim_left']) == 2:
2705
+ ax.set_ylim(geom['ylim_left'][0], geom['ylim_left'][1])
2706
+ if ax2 is not None and 'ylim_right' in geom and isinstance(geom['ylim_right'], list) and len(geom['ylim_right']) == 2:
2707
+ ax2.set_ylim(geom['ylim_right'][0], geom['ylim_right'][1])
2708
+ print("Applied geometry (labels and limits)")
2709
+ fig.canvas.draw_idle()
2710
+ except Exception as e:
2711
+ print(f"Warning: Could not apply geometry: {e}")
2712
+
2713
+ except Exception as e:
2714
+ print(f"Error importing style: {e}")
2715
+ _print_menu(); continue
2716
+ elif key == 'ry':
2717
+ # Toggle efficiency visibility on the right axis
2718
+ try:
2719
+ push_state("toggle-eff")
2720
+
2721
+ # Capture current legend position BEFORE toggling visibility
2722
+ # This ensures the position is preserved when legend is rebuilt
2723
+ try:
2724
+ if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
2725
+ leg0 = ax.get_legend()
2726
+ if leg0 is not None and leg0.get_visible():
2727
+ try:
2728
+ # Ensure renderer exists
2729
+ try:
2730
+ renderer = fig.canvas.get_renderer()
2731
+ except Exception:
2732
+ fig.canvas.draw()
2733
+ renderer = fig.canvas.get_renderer()
2734
+ bb = leg0.get_window_extent(renderer=renderer)
2735
+ cx = 0.5 * (bb.x0 + bb.x1)
2736
+ cy = 0.5 * (bb.y0 + bb.y1)
2737
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
2738
+ fw, fh = fig.get_size_inches()
2739
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
2740
+ offset = _sanitize_legend_offset(offset)
2741
+ if offset is not None:
2742
+ fig._cpc_legend_xy_in = offset
2743
+ except Exception:
2744
+ pass
2745
+ except Exception:
2746
+ pass
2747
+
2748
+ # Determine current visibility state (check if any efficiency is visible)
2749
+ if is_multi_file:
2750
+ # In multi-file mode, check if any efficiency is visible
2751
+ any_eff_visible = any(f.get('sc_eff', {}).get_visible() if hasattr(f.get('sc_eff'), 'get_visible') else True for f in file_data if f.get('sc_eff'))
2752
+ new_vis = not any_eff_visible
2753
+ else:
2754
+ # Single file mode
2755
+ vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2756
+ new_vis = not vis
2757
+
2758
+ # 1. Hide/show efficiency points (all files in multi-file mode)
2759
+ if is_multi_file:
2760
+ for f in file_data:
2761
+ eff_sc = f.get('sc_eff')
2762
+ if eff_sc is not None:
2763
+ try:
2764
+ eff_sc.set_visible(new_vis)
2765
+ except Exception:
2766
+ pass
2767
+ else:
2768
+ sc_eff.set_visible(new_vis)
2769
+
2770
+ # 2. Hide/show right y-axis title
2771
+ try:
2772
+ ax2.yaxis.label.set_visible(new_vis)
2773
+ except Exception:
2774
+ pass
2775
+
2776
+ # 3. Hide/show right y-axis ticks and labels (only affect ax2, don't touch ax)
2777
+ try:
2778
+ ax2.tick_params(axis='y', right=new_vis, labelright=new_vis)
2779
+ # Update tick_state
2780
+ tick_state['ry'] = bool(new_vis)
2781
+ except Exception:
2782
+ pass
2783
+
2784
+ # Persist WASD state so save/load and styles honor the toggle
2785
+ try:
2786
+ wasd = getattr(fig, '_cpc_wasd_state', None)
2787
+ if not isinstance(wasd, dict):
2788
+ wasd = {
2789
+ 'top': {'spine': bool(ax.spines.get('top').get_visible()) if ax.spines.get('top') else False,
2790
+ 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
2791
+ 'minor': bool(tick_state.get('mtx', False)),
2792
+ 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
2793
+ 'title': bool(getattr(ax, '_top_xlabel_on', False))},
2794
+ 'bottom': {'spine': bool(ax.spines.get('bottom').get_visible()) if ax.spines.get('bottom') else True,
2795
+ 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
2796
+ 'minor': bool(tick_state.get('mbx', False)),
2797
+ 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
2798
+ 'title': bool(ax.xaxis.label.get_visible()) and bool(ax.get_xlabel())},
2799
+ 'left': {'spine': bool(ax.spines.get('left').get_visible()) if ax.spines.get('left') else True,
2800
+ 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
2801
+ 'minor': bool(tick_state.get('mly', False)),
2802
+ 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
2803
+ 'title': bool(ax.yaxis.label.get_visible()) and bool(ax.get_ylabel())},
2804
+ 'right': {'spine': bool(ax2.spines.get('right').get_visible()) if ax2.spines.get('right') else True,
2805
+ 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', True))),
2806
+ 'minor': bool(tick_state.get('mry', False)),
2807
+ 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', True))),
2808
+ 'title': bool(ax2.yaxis.label.get_visible()) and bool(ax2.get_ylabel())},
2809
+ }
2810
+ wasd.setdefault('right', {})
2811
+ wasd['right']['ticks'] = bool(new_vis)
2812
+ wasd['right']['labels'] = bool(new_vis)
2813
+ wasd['right']['title'] = bool(new_vis)
2814
+ setattr(fig, '_cpc_wasd_state', wasd)
2815
+ except Exception:
2816
+ pass
2817
+
2818
+ # 4. Rebuild legend to remove/add efficiency entries (preserve position)
2819
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
2820
+
2821
+ fig.canvas.draw_idle()
2822
+ except Exception:
2823
+ pass
2824
+ _print_menu(); continue
2825
+ elif key == 'h':
2826
+ # Legend submenu: toggle visibility, set position in inches relative to canvas center (0,0)
2827
+ try:
2828
+ # If no stored inches yet, try computing from the current legend bbox
2829
+ try:
2830
+ if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
2831
+ leg0 = ax.get_legend()
2832
+ if leg0 is not None:
2833
+ try:
2834
+ # Ensure renderer exists
2835
+ try:
2836
+ renderer = fig.canvas.get_renderer()
2837
+ except Exception:
2838
+ fig.canvas.draw()
2839
+ renderer = fig.canvas.get_renderer()
2840
+ bb = leg0.get_window_extent(renderer=renderer)
2841
+ cx = 0.5 * (bb.x0 + bb.x1)
2842
+ cy = 0.5 * (bb.y0 + bb.y1)
2843
+ # Convert display -> figure fraction
2844
+ fx, fy = fig.transFigure.inverted().transform((cx, cy))
2845
+ fw, fh = fig.get_size_inches()
2846
+ offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
2847
+ offset = _sanitize_legend_offset(offset)
2848
+ if offset is not None:
2849
+ fig._cpc_legend_xy_in = offset
2850
+ except Exception:
2851
+ pass
2852
+ except Exception:
2853
+ pass
2854
+ # Show current status and position
2855
+ leg = ax.get_legend()
2856
+ vis = bool(leg.get_visible()) if leg is not None else False
2857
+ fw, fh = fig.get_size_inches()
2858
+ xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2859
+ xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2860
+ print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2861
+ while True:
2862
+ sub = _safe_input("Legend: t=toggle, p=set position, q=back: ").strip().lower()
2863
+ if not sub:
2864
+ continue
2865
+ if sub == 'q':
2866
+ break
2867
+ if sub == 't':
2868
+ try:
2869
+ push_state("legend-toggle")
2870
+ leg = ax.get_legend()
2871
+ if leg is not None and leg.get_visible():
2872
+ leg.set_visible(False)
2873
+ else:
2874
+ # Ensure a legend exists at the stored position
2875
+ H, L = _visible_handles_labels(ax, ax2)
2876
+ if H:
2877
+ offset = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
2878
+ if offset is not None:
2879
+ fig._cpc_legend_xy_in = offset
2880
+ _apply_legend_position()
2881
+ else:
2882
+ _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2883
+ fig.canvas.draw_idle()
2884
+ except Exception:
2885
+ pass
2886
+ elif sub == 'p':
2887
+ # Position submenu with x and y subcommands
2888
+ while True:
2889
+ xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2890
+ xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2891
+ print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2892
+ pos_cmd = _safe_input("Position: (x y) or x=x only, y=y only, q=back: ").strip().lower()
2893
+ if not pos_cmd or pos_cmd == 'q':
2894
+ break
2895
+ if pos_cmd == 'x':
2896
+ # X only: stay in loop
2897
+ while True:
2898
+ xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2899
+ xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2900
+ print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2901
+ val = _safe_input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
2902
+ if not val or val.lower() == 'q':
2903
+ break
2904
+ try:
2905
+ x_in = float(val)
2906
+ except (ValueError, KeyboardInterrupt):
2907
+ print("Invalid number, ignored.")
2908
+ continue
2909
+ push_state("legend-position")
2910
+ try:
2911
+ fig._cpc_legend_xy_in = (x_in, xy_in[1])
2912
+ fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2913
+ _apply_legend_position()
2914
+ fig.canvas.draw_idle()
2915
+ print(f"Legend position updated: x={x_in:.2f}, y={xy_in[1]:.2f}")
2916
+ except Exception:
2917
+ pass
2918
+ elif pos_cmd == 'y':
2919
+ # Y only: stay in loop
2920
+ while True:
2921
+ xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2922
+ xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2923
+ print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2924
+ val = _safe_input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
2925
+ if not val or val.lower() == 'q':
2926
+ break
2927
+ try:
2928
+ y_in = float(val)
2929
+ except (ValueError, KeyboardInterrupt):
2930
+ print("Invalid number, ignored.")
2931
+ continue
2932
+ push_state("legend-position")
2933
+ try:
2934
+ fig._cpc_legend_xy_in = (xy_in[0], y_in)
2935
+ fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2936
+ _apply_legend_position()
2937
+ fig.canvas.draw_idle()
2938
+ print(f"Legend position updated: x={xy_in[0]:.2f}, y={y_in:.2f}")
2939
+ except Exception:
2940
+ pass
2941
+ else:
2942
+ # Try to parse as "x y" format
2943
+ parts = pos_cmd.replace(',', ' ').split()
2944
+ if len(parts) != 2:
2945
+ print("Need two numbers or 'x'/'y' command."); continue
2946
+ try:
2947
+ x_in = float(parts[0]); y_in = float(parts[1])
2948
+ except Exception:
2949
+ print("Invalid numbers."); continue
2950
+ push_state("legend-position")
2951
+ try:
2952
+ fig._cpc_legend_xy_in = (x_in, y_in)
2953
+ fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2954
+ _apply_legend_position()
2955
+ fig.canvas.draw_idle()
2956
+ print(f"Legend position updated: x={x_in:.2f}, y={y_in:.2f}")
2957
+ except Exception:
2958
+ pass
2959
+ else:
2960
+ print("Unknown option.")
2961
+ except Exception:
2962
+ pass
2963
+ _print_menu(); continue
2964
+ elif key == 'f':
2965
+ sub = _safe_input("Font: f=family, s=size, q=back: ").strip().lower()
2966
+ if sub == 'q' or not sub:
2967
+ _print_menu(); continue
2968
+ if sub == 'f':
2969
+ fam = _safe_input("Enter font family (e.g., Arial, DejaVu Sans): ").strip()
2970
+ if fam:
2971
+ try:
2972
+ push_state("font-family")
2973
+ plt.rcParams['font.family'] = 'sans-serif'
2974
+ plt.rcParams['font.sans-serif'] = [fam, 'DejaVu Sans', 'Arial', 'Helvetica']
2975
+ # Apply to labels, ticks, and duplicate artists immediately
2976
+ for a in (ax, ax2):
2977
+ try:
2978
+ a.xaxis.label.set_family(fam); a.yaxis.label.set_family(fam)
2979
+ except Exception:
2980
+ pass
2981
+ try:
2982
+ for t in a.get_xticklabels() + a.get_yticklabels():
2983
+ t.set_family(fam)
2984
+ except Exception:
2985
+ pass
2986
+ # Update top and right tick labels (label2)
2987
+ try:
2988
+ for tick in a.xaxis.get_major_ticks():
2989
+ if hasattr(tick, 'label2'):
2990
+ tick.label2.set_family(fam)
2991
+ for tick in a.yaxis.get_major_ticks():
2992
+ if hasattr(tick, 'label2'):
2993
+ tick.label2.set_family(fam)
2994
+ except Exception:
2995
+ pass
2996
+ try:
2997
+ art = getattr(ax, '_top_xlabel_artist', None)
2998
+ if art is not None:
2999
+ art.set_fontfamily(fam)
3000
+ # Also update the new text artist
3001
+ txt = getattr(ax, '_top_xlabel_text', None)
3002
+ if txt is not None:
3003
+ txt.set_fontfamily(fam)
3004
+ except Exception:
3005
+ pass
3006
+ try:
3007
+ # Right ylabel artist is on ax2, not ax
3008
+ art = getattr(ax2, '_right_ylabel_artist', None)
3009
+ if art is not None:
3010
+ art.set_fontfamily(fam)
3011
+ except Exception:
3012
+ pass
3013
+ # Update legend font
3014
+ try:
3015
+ leg = ax.get_legend()
3016
+ if leg is not None:
3017
+ for txt in leg.get_texts():
3018
+ txt.set_fontfamily(fam)
3019
+ except Exception:
3020
+ pass
3021
+ fig.canvas.draw_idle()
3022
+ except Exception:
3023
+ pass
3024
+ elif sub == 's':
3025
+ val = _safe_input("Enter font size (number): ").strip()
3026
+ try:
3027
+ size = float(val)
3028
+ push_state("font-size")
3029
+ plt.rcParams['font.size'] = size
3030
+ # Apply to labels, ticks, and duplicate artists immediately
3031
+ for a in (ax, ax2):
3032
+ try:
3033
+ a.xaxis.label.set_size(size)
3034
+ a.yaxis.label.set_size(size)
3035
+ except Exception:
3036
+ pass
3037
+ try:
3038
+ for t in a.get_xticklabels() + a.get_yticklabels():
3039
+ t.set_size(size)
3040
+ except Exception:
3041
+ pass
3042
+ # Update top and right tick labels (label2)
3043
+ try:
3044
+ for tick in a.xaxis.get_major_ticks():
3045
+ if hasattr(tick, 'label2'):
3046
+ tick.label2.set_size(size)
3047
+ for tick in a.yaxis.get_major_ticks():
3048
+ if hasattr(tick, 'label2'):
3049
+ tick.label2.set_size(size)
3050
+ except Exception:
3051
+ pass
3052
+ try:
3053
+ art = getattr(ax, '_top_xlabel_artist', None)
3054
+ if art is not None:
3055
+ art.set_fontsize(size)
3056
+ # Also update the new text artist
3057
+ txt = getattr(ax, '_top_xlabel_text', None)
3058
+ if txt is not None:
3059
+ txt.set_fontsize(size)
3060
+ except Exception:
3061
+ pass
3062
+ try:
3063
+ # Right ylabel artist is on ax2, not ax
3064
+ art = getattr(ax2, '_right_ylabel_artist', None)
3065
+ if art is not None:
3066
+ art.set_fontsize(size)
3067
+ except Exception:
3068
+ pass
3069
+ # Update legend font size
3070
+ try:
3071
+ leg = ax.get_legend()
3072
+ if leg is not None:
3073
+ for txt in leg.get_texts():
3074
+ txt.set_fontsize(size)
3075
+ except Exception:
3076
+ pass
3077
+ fig.canvas.draw_idle()
3078
+ except Exception:
3079
+ print("Invalid size.")
3080
+ _print_menu(); continue
3081
+ elif key == 'l':
3082
+ # Line widths submenu: frame/ticks vs grid
3083
+ try:
3084
+ def _tick_width(axis_obj, which: str):
3085
+ try:
3086
+ tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
3087
+ width = tick_kw.get('width')
3088
+ if width is None:
3089
+ axis_name = getattr(axis_obj, 'axis_name', 'x')
3090
+ rc_key = f"{axis_name}tick.{which}.width"
3091
+ width = plt.rcParams.get(rc_key)
3092
+ if width is not None:
3093
+ return float(width)
3094
+ except Exception:
3095
+ return None
3096
+ return None
3097
+ while True:
3098
+ # Show current widths summary
3099
+ try:
3100
+ cur_sp_lw = {name: (ax.spines.get(name).get_linewidth() if ax.spines.get(name) else None)
3101
+ for name in ('bottom','top','left','right')}
3102
+ except Exception:
3103
+ cur_sp_lw = {}
3104
+ x_maj = _tick_width(ax.xaxis, 'major')
3105
+ x_min = _tick_width(ax.xaxis, 'minor')
3106
+ ly_maj = _tick_width(ax.yaxis, 'major')
3107
+ ly_min = _tick_width(ax.yaxis, 'minor')
3108
+ ry_maj = _tick_width(ax2.yaxis, 'major')
3109
+ ry_min = _tick_width(ax2.yaxis, 'minor')
3110
+ print("Line widths:")
3111
+ if cur_sp_lw:
3112
+ print(" Frame spines lw:",
3113
+ " ".join(f"{k}={v:.3g}" if isinstance(v,(int,float)) else f"{k}=?" for k,v in cur_sp_lw.items()))
3114
+ print(f" Tick widths: xM={x_maj if x_maj is not None else '?'} xm={x_min if x_min is not None else '?'} lyM={ly_maj if ly_maj is not None else '?'} lym={ly_min if ly_min is not None else '?'} ryM={ry_maj if ry_maj is not None else '?'} rym={ry_min if ry_min is not None else '?'}")
3115
+ print("\033[1mLine submenu:\033[0m")
3116
+ print(f" {_colorize_menu('f : change frame (axes spines) and tick widths')}")
3117
+ print(f" {_colorize_menu('g : toggle grid lines')}")
3118
+ print(f" {_colorize_menu('q : return')}")
3119
+ sub = _safe_input(_colorize_prompt("Choose (f/g/q): ")).strip().lower()
3120
+ if not sub:
3121
+ continue
3122
+ if sub == 'q':
3123
+ break
3124
+ if sub == 'f':
3125
+ fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
3126
+ if not fw_in or fw_in.lower() == 'q':
3127
+ print("Canceled.")
3128
+ continue
3129
+ parts = fw_in.split()
3130
+ try:
3131
+ push_state("framewidth")
3132
+ if len(parts) == 1:
3133
+ frame_w = float(parts[0])
3134
+ tick_major = frame_w
3135
+ tick_minor = frame_w * 0.6
3136
+ else:
3137
+ frame_w = float(parts[0])
3138
+ tick_major = float(parts[1])
3139
+ tick_minor = float(tick_major) * 0.7
3140
+ # Set frame width for all spines (ax and ax2)
3141
+ for sp in ax.spines.values():
3142
+ sp.set_linewidth(frame_w)
3143
+ for sp in ax2.spines.values():
3144
+ sp.set_linewidth(frame_w)
3145
+ # Set tick widths for both axes
3146
+ ax.tick_params(which='major', width=tick_major)
3147
+ ax.tick_params(which='minor', width=tick_minor)
3148
+ ax2.tick_params(which='major', width=tick_major)
3149
+ ax2.tick_params(which='minor', width=tick_minor)
3150
+ fig.canvas.draw()
3151
+ print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
3152
+ except ValueError:
3153
+ print("Invalid numeric value(s).")
3154
+ elif sub == 'g':
3155
+ push_state("grid")
3156
+ # Toggle grid state - check if any gridlines are visible
3157
+ current_grid = False
3158
+ try:
3159
+ # Check if grid is currently on by looking at gridline visibility
3160
+ for line in ax.get_xgridlines() + ax.get_ygridlines():
3161
+ if line.get_visible():
3162
+ current_grid = True
3163
+ break
3164
+ except Exception:
3165
+ current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
3166
+
3167
+ new_grid_state = not current_grid
3168
+ if new_grid_state:
3169
+ # Enable grid with light styling
3170
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
3171
+ else:
3172
+ # Disable grid
3173
+ ax.grid(False)
3174
+ fig.canvas.draw()
3175
+ print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
3176
+ else:
3177
+ print("Unknown option.")
3178
+ except Exception as e:
3179
+ print(f"Error in line submenu: {e}")
3180
+ _print_menu(); continue
3181
+ elif key == 'm':
3182
+ try:
3183
+ print("Current marker sizes:")
3184
+ try:
3185
+ c_ms = getattr(sc_charge, 'get_sizes', lambda: [32])()[0]
3186
+ except Exception:
3187
+ c_ms = 32
3188
+ try:
3189
+ d_ms = getattr(sc_discharge, 'get_sizes', lambda: [32])()[0]
3190
+ except Exception:
3191
+ d_ms = 32
3192
+ try:
3193
+ e_ms = getattr(sc_eff, 'get_sizes', lambda: [40])()[0]
3194
+ except Exception:
3195
+ e_ms = 40
3196
+ print(f" charge ms={c_ms}, discharge ms={d_ms}, efficiency ms={e_ms}")
3197
+ spec = _safe_input("Set marker size: 'c <ms>', 'd <ms>', 'e <ms>' (q=cancel): ").strip().lower()
3198
+ if not spec or spec == 'q':
3199
+ _print_menu(); continue
3200
+ parts = spec.split()
3201
+ if len(parts) != 2:
3202
+ print("Need two tokens."); _print_menu(); continue
3203
+ role, val = parts[0], parts[1]
3204
+ try:
3205
+ num = float(val)
3206
+ push_state("marker-size")
3207
+ if role == 'c' and hasattr(sc_charge, 'set_sizes'):
3208
+ sc_charge.set_sizes([num])
3209
+ elif role == 'd' and hasattr(sc_discharge, 'set_sizes'):
3210
+ sc_discharge.set_sizes([num])
3211
+ elif role == 'e' and hasattr(sc_eff, 'set_sizes'):
3212
+ sc_eff.set_sizes([num])
3213
+ fig.canvas.draw_idle()
3214
+ except Exception:
3215
+ print("Invalid value.")
3216
+ except Exception as e:
3217
+ print(f"Error: {e}")
3218
+ _print_menu(); continue
3219
+ elif key == 't':
3220
+ # Unified WASD toggles for spines/ticks/minor/labels/title per side
3221
+ # Import UI positioning functions locally to ensure they're accessible in nested functions
3222
+ from .ui import position_top_xlabel as _ui_position_top_xlabel, position_bottom_xlabel as _ui_position_bottom_xlabel, position_left_ylabel as _ui_position_left_ylabel, position_right_ylabel as _ui_position_right_ylabel
3223
+
3224
+ try:
3225
+ # Local WASD state stored on figure to persist across openings
3226
+ wasd = getattr(fig, '_cpc_wasd_state', None)
3227
+ if not isinstance(wasd, dict):
3228
+ wasd = {
3229
+ 'top': {'spine': bool(ax.spines.get('top').get_visible()) if ax.spines.get('top') else False,
3230
+ 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
3231
+ 'minor': bool(tick_state.get('mtx', False)),
3232
+ 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
3233
+ 'title': bool(getattr(ax, '_top_xlabel_on', False))},
3234
+ 'bottom': {'spine': bool(ax.spines.get('bottom').get_visible()) if ax.spines.get('bottom') else True,
3235
+ 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
3236
+ 'minor': bool(tick_state.get('mbx', False)),
3237
+ 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
3238
+ 'title': bool(ax.get_xlabel())},
3239
+ 'left': {'spine': bool(ax.spines.get('left').get_visible()) if ax.spines.get('left') else True,
3240
+ 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
3241
+ 'minor': bool(tick_state.get('mly', False)),
3242
+ 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
3243
+ 'title': bool(ax.get_ylabel())},
3244
+ 'right': {'spine': bool(ax2.spines.get('right').get_visible()) if ax2.spines.get('right') else True,
3245
+ 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', True))),
3246
+ 'minor': bool(tick_state.get('mry', False)),
3247
+ 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', True))),
3248
+ 'title': bool(ax2.yaxis.get_label().get_text()) and bool(sc_eff.get_visible())},
3249
+ }
3250
+ setattr(fig, '_cpc_wasd_state', wasd)
3251
+
3252
+ def _apply_wasd(changed_sides=None):
3253
+ # If no changed_sides specified, reposition all sides (for load style, etc.)
3254
+ if changed_sides is None:
3255
+ changed_sides = {'bottom', 'top', 'left', 'right'}
3256
+
3257
+ # Apply spines
3258
+ # Note: top and bottom spines are shared between ax and ax2
3259
+ try:
3260
+ ax.spines['top'].set_visible(bool(wasd['top']['spine']))
3261
+ ax.spines['bottom'].set_visible(bool(wasd['bottom']['spine']))
3262
+ ax.spines['left'].set_visible(bool(wasd['left']['spine']))
3263
+ # Also control top/bottom on ax2 since they're shared
3264
+ ax2.spines['top'].set_visible(bool(wasd['top']['spine']))
3265
+ ax2.spines['bottom'].set_visible(bool(wasd['bottom']['spine']))
3266
+ except Exception:
3267
+ pass
3268
+ try:
3269
+ ax2.spines['right'].set_visible(bool(wasd['right']['spine']))
3270
+ except Exception:
3271
+ pass
3272
+ # Major ticks and tick labels
3273
+ try:
3274
+ ax.tick_params(axis='x', top=bool(wasd['top']['ticks']), bottom=bool(wasd['bottom']['ticks']),
3275
+ labeltop=bool(wasd['top']['labels']), labelbottom=bool(wasd['bottom']['labels']))
3276
+ ax.tick_params(axis='y', left=bool(wasd['left']['ticks']), labelleft=bool(wasd['left']['labels']))
3277
+ ax2.tick_params(axis='y', right=bool(wasd['right']['ticks']), labelright=bool(wasd['right']['labels']))
3278
+ except Exception:
3279
+ pass
3280
+ # Minor ticks
3281
+ try:
3282
+ if wasd['top']['minor'] or wasd['bottom']['minor']:
3283
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
3284
+ ax.xaxis.set_minor_formatter(NullFormatter())
3285
+ else:
3286
+ # Clear minor locator if no minor ticks are enabled
3287
+ ax.xaxis.set_minor_locator(NullLocator())
3288
+ ax.xaxis.set_minor_formatter(NullFormatter())
3289
+ ax.tick_params(axis='x', which='minor', top=bool(wasd['top']['minor']), bottom=bool(wasd['bottom']['minor']),
3290
+ labeltop=False, labelbottom=False)
3291
+ except Exception:
3292
+ pass
3293
+ try:
3294
+ if wasd['left']['minor']:
3295
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
3296
+ ax.yaxis.set_minor_formatter(NullFormatter())
3297
+ else:
3298
+ # Clear minor locator if no minor ticks are enabled
3299
+ ax.yaxis.set_minor_locator(NullLocator())
3300
+ ax.yaxis.set_minor_formatter(NullFormatter())
3301
+ ax.tick_params(axis='y', which='minor', left=bool(wasd['left']['minor']), labelleft=False)
3302
+ except Exception:
3303
+ pass
3304
+ try:
3305
+ if wasd['right']['minor']:
3306
+ ax2.yaxis.set_minor_locator(AutoMinorLocator())
3307
+ ax2.yaxis.set_minor_formatter(NullFormatter())
3308
+ else:
3309
+ # Clear minor locator if no minor ticks are enabled
3310
+ ax2.yaxis.set_minor_locator(NullLocator())
3311
+ ax2.yaxis.set_minor_formatter(NullFormatter())
3312
+ ax2.tick_params(axis='y', which='minor', right=bool(wasd['right']['minor']), labelright=False)
3313
+ except Exception:
3314
+ pass
3315
+ # Titles
3316
+ try:
3317
+ # Bottom X title
3318
+ if bool(wasd['bottom']['title']):
3319
+ # Restore stored xlabel if present
3320
+ if hasattr(ax, '_stored_xlabel') and isinstance(ax._stored_xlabel, str) and ax._stored_xlabel:
3321
+ ax.set_xlabel(ax._stored_xlabel)
3322
+ else:
3323
+ # Store once
3324
+ if not hasattr(ax, '_stored_xlabel'):
3325
+ try:
3326
+ ax._stored_xlabel = ax.get_xlabel()
3327
+ except Exception:
3328
+ ax._stored_xlabel = ''
3329
+ ax.set_xlabel("")
3330
+ except Exception:
3331
+ pass
3332
+ try:
3333
+ # Top X title - create a text artist positioned at the top
3334
+ # First ensure we have the original xlabel text stored
3335
+ if not hasattr(ax, '_stored_top_xlabel') or not ax._stored_top_xlabel:
3336
+ # Try to get from current xlabel first
3337
+ current_xlabel = ax.get_xlabel()
3338
+ if current_xlabel:
3339
+ ax._stored_top_xlabel = current_xlabel
3340
+ # If still empty, try from stored bottom xlabel
3341
+ elif hasattr(ax, '_stored_xlabel') and ax._stored_xlabel:
3342
+ ax._stored_top_xlabel = ax._stored_xlabel
3343
+ else:
3344
+ ax._stored_top_xlabel = ''
3345
+
3346
+ if bool(wasd['top']['title']) and ax._stored_top_xlabel:
3347
+ # Get or create the top xlabel artist
3348
+ if not hasattr(ax, '_top_xlabel_text') or ax._top_xlabel_text is None:
3349
+ # Create a new text artist at the top center
3350
+ ax._top_xlabel_text = ax.text(0.5, 1.0, '', transform=ax.transAxes,
3351
+ ha='center', va='bottom',
3352
+ fontsize=ax.xaxis.label.get_fontsize(),
3353
+ fontfamily=ax.xaxis.label.get_fontfamily())
3354
+ # Update text and make visible
3355
+ ax._top_xlabel_text.set_text(ax._stored_top_xlabel)
3356
+ ax._top_xlabel_text.set_visible(True)
3357
+
3358
+ # Dynamic positioning based on top tick labels visibility
3359
+ # Only reposition top if it's in changed_sides
3360
+ if 'top' in changed_sides:
3361
+ try:
3362
+ # Get renderer for measurements
3363
+ renderer = fig.canvas.get_renderer()
3364
+
3365
+ # Base padding
3366
+ labelpad = ax.xaxis.labelpad if hasattr(ax.xaxis, 'labelpad') else 4.0
3367
+ fig_h = fig.get_size_inches()[1]
3368
+ ax_bbox = ax.get_position()
3369
+ ax_h_inches = ax_bbox.height * fig_h
3370
+ base_pad_axes = (labelpad / 72.0) / ax_h_inches if ax_h_inches > 0 else 0.02
3371
+
3372
+ # If top tick labels are visible, measure their height and add spacing
3373
+ extra_offset = 0.0
3374
+ if bool(wasd['top']['labels']) and renderer is not None:
3375
+ try:
3376
+ max_h_px = 0.0
3377
+ for t in ax.xaxis.get_major_ticks():
3378
+ lab = getattr(t, 'label2', None) # Top labels are label2
3379
+ if lab is not None and lab.get_visible():
3380
+ bb = lab.get_window_extent(renderer=renderer)
3381
+ if bb is not None:
3382
+ max_h_px = max(max_h_px, float(bb.height))
3383
+ # Convert pixels to axes coordinates
3384
+ if max_h_px > 0 and ax_h_inches > 0:
3385
+ dpi = float(fig.dpi) if hasattr(fig, 'dpi') else 100.0
3386
+ max_h_inches = max_h_px / dpi
3387
+ extra_offset = max_h_inches / ax_h_inches
3388
+ except Exception:
3389
+ # Fallback to fixed offset if labels are on
3390
+ extra_offset = 0.05
3391
+
3392
+ total_offset = 1.0 + base_pad_axes + extra_offset
3393
+ ax._top_xlabel_text.set_position((0.5, total_offset))
3394
+ except Exception:
3395
+ # Fallback positioning
3396
+ if bool(wasd['top']['labels']):
3397
+ ax._top_xlabel_text.set_position((0.5, 1.07))
3398
+ else:
3399
+ ax._top_xlabel_text.set_position((0.5, 1.02))
3400
+ else:
3401
+ # Hide top label
3402
+ if hasattr(ax, '_top_xlabel_text') and ax._top_xlabel_text is not None:
3403
+ ax._top_xlabel_text.set_visible(False)
3404
+ except Exception:
3405
+ pass
3406
+ try:
3407
+ # Left Y title
3408
+ if bool(wasd['left']['title']):
3409
+ if hasattr(ax, '_stored_ylabel') and isinstance(ax._stored_ylabel, str) and ax._stored_ylabel:
3410
+ ax.set_ylabel(ax._stored_ylabel)
3411
+ else:
3412
+ if not hasattr(ax, '_stored_ylabel'):
3413
+ try:
3414
+ ax._stored_ylabel = ax.get_ylabel()
3415
+ except Exception:
3416
+ ax._stored_ylabel = ''
3417
+ ax.set_ylabel("")
3418
+ except Exception:
3419
+ pass
3420
+ try:
3421
+ # Right Y title - simple approach like left/bottom
3422
+ if bool(wasd['right']['title']) and bool(sc_eff.get_visible()):
3423
+ if hasattr(ax2, '_stored_ylabel') and isinstance(ax2._stored_ylabel, str) and ax2._stored_ylabel:
3424
+ ax2.set_ylabel(ax2._stored_ylabel)
3425
+ else:
3426
+ if not hasattr(ax2, '_stored_ylabel'):
3427
+ try:
3428
+ ax2._stored_ylabel = ax2.get_ylabel()
3429
+ except Exception:
3430
+ ax2._stored_ylabel = ''
3431
+ ax2.set_ylabel("")
3432
+ except Exception:
3433
+ pass
3434
+
3435
+ # Only reposition sides that were actually changed
3436
+ # This prevents unnecessary title movement when toggling unrelated elements
3437
+ if 'bottom' in changed_sides:
3438
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
3439
+ if 'left' in changed_sides:
3440
+ _ui_position_left_ylabel(ax, fig, tick_state)
3441
+
3442
+ def _print_wasd():
3443
+ def b(v):
3444
+ return 'ON ' if bool(v) else 'off'
3445
+ print(_colorize_inline_commands("State (top/bottom/left/right):"))
3446
+ print(_colorize_inline_commands(f" top w1:{b(wasd['top']['spine'])} w2:{b(wasd['top']['ticks'])} w3:{b(wasd['top']['minor'])} w4:{b(wasd['top']['labels'])} w5:{b(wasd['top']['title'])}"))
3447
+ print(_colorize_inline_commands(f" bottom s1:{b(wasd['bottom']['spine'])} s2:{b(wasd['bottom']['ticks'])} s3:{b(wasd['bottom']['minor'])} s4:{b(wasd['bottom']['labels'])} s5:{b(wasd['bottom']['title'])}"))
3448
+ print(_colorize_inline_commands(f" left a1:{b(wasd['left']['spine'])} a2:{b(wasd['left']['ticks'])} a3:{b(wasd['left']['minor'])} a4:{b(wasd['left']['labels'])} a5:{b(wasd['left']['title'])}"))
3449
+ print(_colorize_inline_commands(f" right d1:{b(wasd['right']['spine'])} d2:{b(wasd['right']['ticks'])} d3:{b(wasd['right']['minor'])} d4:{b(wasd['right']['labels'])} d5:{b(wasd['right']['title'])}"))
3450
+
3451
+ print(_colorize_inline_commands("WASD toggles: direction (w/a/s/d) x action (1..5)"))
3452
+ print(_colorize_inline_commands(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title"))
3453
+ print(_colorize_inline_commands("Examples: 'w2 w5' to toggle top ticks and top title; 'd2 d5' for right."))
3454
+ print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' to show current state, 'q' to go back."))
3455
+ print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
3456
+ while True:
3457
+ cmd = _safe_input(_colorize_prompt("t> ")).strip().lower()
3458
+ if not cmd:
3459
+ continue
3460
+ if cmd == 'q':
3461
+ break
3462
+ if cmd == 'i':
3463
+ # Invert tick direction (toggle between 'out' and 'in')
3464
+ push_state("tick-direction")
3465
+ current_dir = getattr(fig, '_tick_direction', 'out')
3466
+ new_dir = 'in' if current_dir == 'out' else 'out'
3467
+ setattr(fig, '_tick_direction', new_dir)
3468
+ ax.tick_params(axis='both', which='both', direction=new_dir)
3469
+ ax2.tick_params(axis='both', which='both', direction=new_dir)
3470
+ print(f"Tick direction: {new_dir}")
3471
+ try:
3472
+ fig.canvas.draw()
3473
+ except Exception:
3474
+ fig.canvas.draw_idle()
3475
+ continue
3476
+ if cmd == 'l':
3477
+ # Change tick length (major and minor automatically set to 70%)
3478
+ try:
3479
+ # Get current major tick length from axes
3480
+ current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
3481
+ print(f"Current major tick length: {current_major}")
3482
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
3483
+ if not new_length_str:
3484
+ continue
3485
+ new_major = float(new_length_str)
3486
+ if new_major <= 0:
3487
+ print("Length must be positive.")
3488
+ continue
3489
+ new_minor = new_major * 0.7 # Auto-set minor to 70%
3490
+ push_state("tick-length")
3491
+ # Apply to all four axes on both ax and ax2
3492
+ ax.tick_params(axis='both', which='major', length=new_major)
3493
+ ax.tick_params(axis='both', which='minor', length=new_minor)
3494
+ ax2.tick_params(axis='both', which='major', length=new_major)
3495
+ ax2.tick_params(axis='both', which='minor', length=new_minor)
3496
+ # Store for persistence
3497
+ if not hasattr(fig, '_tick_lengths'):
3498
+ fig._tick_lengths = {}
3499
+ fig._tick_lengths.update({'major': new_major, 'minor': new_minor})
3500
+ print(f"Set major tick length: {new_major}, minor: {new_minor:.2f}")
3501
+ try:
3502
+ fig.canvas.draw()
3503
+ except Exception:
3504
+ fig.canvas.draw_idle()
3505
+ except ValueError:
3506
+ print("Invalid number.")
3507
+ except Exception as e:
3508
+ print(f"Error setting tick length: {e}")
3509
+ continue
3510
+ if cmd == 'list':
3511
+ _print_wasd(); continue
3512
+ if cmd == 'p':
3513
+ # Title offset menu
3514
+ def _dpi():
3515
+ try:
3516
+ return float(fig.dpi)
3517
+ except Exception:
3518
+ return 72.0
3519
+
3520
+ def _px_value(attr):
3521
+ try:
3522
+ pts = float(getattr(ax, attr, 0.0) or 0.0)
3523
+ except Exception:
3524
+ pts = 0.0
3525
+ return pts * _dpi() / 72.0
3526
+
3527
+ def _set_attr(attr, pts):
3528
+ try:
3529
+ setattr(ax, attr, float(pts))
3530
+ except Exception:
3531
+ pass
3532
+
3533
+ def _nudge(attr, delta_px):
3534
+ try:
3535
+ current_pts = float(getattr(ax, attr, 0.0) or 0.0)
3536
+ except Exception:
3537
+ current_pts = 0.0
3538
+ delta_pts = float(delta_px) * 72.0 / _dpi()
3539
+ _set_attr(attr, current_pts + delta_pts)
3540
+
3541
+ snapshot_taken = False
3542
+
3543
+ def _ensure_snapshot():
3544
+ nonlocal snapshot_taken
3545
+ if not snapshot_taken:
3546
+ push_state("title-offset")
3547
+ snapshot_taken = True
3548
+
3549
+ def _top_menu():
3550
+ if not getattr(ax, '_top_xlabel_on', False):
3551
+ print("Top duplicate title is currently hidden (enable with w5).")
3552
+ return
3553
+ while True:
3554
+ current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
3555
+ current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
3556
+ print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
3557
+ sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
3558
+ if not sub:
3559
+ continue
3560
+ if sub == 'q':
3561
+ break
3562
+ if sub == '0':
3563
+ _ensure_snapshot()
3564
+ _set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
3565
+ _set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
3566
+ elif sub == 'w':
3567
+ _ensure_snapshot()
3568
+ _nudge('_top_xlabel_manual_offset_y_pts', +1.0)
3569
+ elif sub == 's':
3570
+ _ensure_snapshot()
3571
+ _nudge('_top_xlabel_manual_offset_y_pts', -1.0)
3572
+ elif sub == 'a':
3573
+ _ensure_snapshot()
3574
+ _nudge('_top_xlabel_manual_offset_x_pts', -1.0)
3575
+ elif sub == 'd':
3576
+ _ensure_snapshot()
3577
+ _nudge('_top_xlabel_manual_offset_x_pts', +1.0)
3578
+ else:
3579
+ print("Unknown choice (use w/s/a/d/0/q).")
3580
+ continue
3581
+ _ui_position_top_xlabel(ax, fig, tick_state)
3582
+ try:
3583
+ fig.canvas.draw_idle()
3584
+ except Exception:
3585
+ pass
3586
+
3587
+ def _bottom_menu():
3588
+ if not ax.get_xlabel():
3589
+ print("Bottom title is currently hidden.")
3590
+ return
3591
+ while True:
3592
+ current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
3593
+ print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
3594
+ sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
3595
+ if not sub:
3596
+ continue
3597
+ if sub == 'q':
3598
+ break
3599
+ if sub == '0':
3600
+ _ensure_snapshot()
3601
+ _set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
3602
+ elif sub == 's':
3603
+ _ensure_snapshot()
3604
+ _nudge('_bottom_xlabel_manual_offset_y_pts', +1.0)
3605
+ elif sub == 'w':
3606
+ _ensure_snapshot()
3607
+ _nudge('_bottom_xlabel_manual_offset_y_pts', -1.0)
3608
+ else:
3609
+ print("Unknown choice (use s/w/0/q).")
3610
+ continue
3611
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
3612
+ try:
3613
+ fig.canvas.draw_idle()
3614
+ except Exception:
3615
+ pass
3616
+
3617
+ def _left_menu():
3618
+ if not ax.get_ylabel():
3619
+ print("Left title is currently hidden.")
3620
+ return
3621
+ while True:
3622
+ current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
3623
+ print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
3624
+ sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
3625
+ if not sub:
3626
+ continue
3627
+ if sub == 'q':
3628
+ break
3629
+ if sub == '0':
3630
+ _ensure_snapshot()
3631
+ _set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
3632
+ elif sub == 'a':
3633
+ _ensure_snapshot()
3634
+ _nudge('_left_ylabel_manual_offset_x_pts', +1.0)
3635
+ elif sub == 'd':
3636
+ _ensure_snapshot()
3637
+ _nudge('_left_ylabel_manual_offset_x_pts', -1.0)
3638
+ else:
3639
+ print("Unknown choice (use a/d/0/q).")
3640
+ continue
3641
+ _ui_position_left_ylabel(ax, fig, tick_state)
3642
+ try:
3643
+ fig.canvas.draw_idle()
3644
+ except Exception:
3645
+ pass
3646
+
3647
+ def _right_menu():
3648
+ if not ax2.get_ylabel():
3649
+ print("Right title is currently hidden.")
3650
+ return
3651
+ while True:
3652
+ current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
3653
+ current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
3654
+ print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
3655
+ sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
3656
+ if not sub:
3657
+ continue
3658
+ if sub == 'q':
3659
+ break
3660
+ if sub == '0':
3661
+ _ensure_snapshot()
3662
+ _set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
3663
+ _set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
3664
+ elif sub == 'd':
3665
+ _ensure_snapshot()
3666
+ _nudge('_right_ylabel_manual_offset_x_pts', +1.0)
3667
+ elif sub == 'a':
3668
+ _ensure_snapshot()
3669
+ _nudge('_right_ylabel_manual_offset_x_pts', -1.0)
3670
+ elif sub == 'w':
3671
+ _ensure_snapshot()
3672
+ _nudge('_right_ylabel_manual_offset_y_pts', +1.0)
3673
+ elif sub == 's':
3674
+ _ensure_snapshot()
3675
+ _nudge('_right_ylabel_manual_offset_y_pts', -1.0)
3676
+ else:
3677
+ print("Unknown choice (use d/a/w/s/0/q).")
3678
+ continue
3679
+ _ui_position_right_ylabel(ax2, fig, tick_state)
3680
+ try:
3681
+ fig.canvas.draw_idle()
3682
+ except Exception:
3683
+ pass
3684
+
3685
+ while True:
3686
+ print(_colorize_inline_commands("Title offsets:"))
3687
+ print(" " + _colorize_menu('w : adjust top title (w=up, s=down, a=left, d=right)'))
3688
+ print(" " + _colorize_menu('s : adjust bottom title (s=down, w=up)'))
3689
+ print(" " + _colorize_menu('a : adjust left title (a=left, d=right)'))
3690
+ print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
3691
+ print(" " + _colorize_menu('r : reset all offsets'))
3692
+ print(" " + _colorize_menu('q : return'))
3693
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
3694
+ if not choice:
3695
+ continue
3696
+ if choice == 'q':
3697
+ break
3698
+ if choice == 'w':
3699
+ _top_menu()
3700
+ continue
3701
+ if choice == 's':
3702
+ _bottom_menu()
3703
+ continue
3704
+ if choice == 'a':
3705
+ _left_menu()
3706
+ continue
3707
+ if choice == 'd':
3708
+ _right_menu()
3709
+ continue
3710
+ if choice == 'r':
3711
+ _ensure_snapshot()
3712
+ _set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
3713
+ _set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
3714
+ _set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
3715
+ _set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
3716
+ _set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
3717
+ _set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
3718
+ _ui_position_top_xlabel(ax, fig, tick_state)
3719
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
3720
+ _ui_position_left_ylabel(ax, fig, tick_state)
3721
+ _ui_position_right_ylabel(ax2, fig, tick_state)
3722
+ try:
3723
+ fig.canvas.draw_idle()
3724
+ except Exception:
3725
+ pass
3726
+ print("Reset manual offsets for all titles.")
3727
+ continue
3728
+ print("Unknown option. Use w/s/a/d/r/q.")
3729
+ continue
3730
+ parts = cmd.split()
3731
+ changed = False
3732
+ changed_sides = set() # Track which sides were affected
3733
+ for p in parts:
3734
+ if len(p) != 2:
3735
+ print(f"Unknown code: {p}"); continue
3736
+ d, n = p[0], p[1]
3737
+ side = {'w':'top','a':'left','s':'bottom','d':'right'}.get(d)
3738
+ if side is None or n not in '12345':
3739
+ print(f"Unknown code: {p}"); continue
3740
+ key = { '1':'spine', '2':'ticks', '3':'minor', '4':'labels', '5':'title' }[n]
3741
+ wasd[side][key] = not bool(wasd[side][key])
3742
+ changed = True
3743
+ # Track which side was changed to only reposition affected sides
3744
+ # Labels and titles affect positioning, but spine/tick toggles don't necessarily
3745
+ if key in ('labels', 'title'):
3746
+ changed_sides.add(side)
3747
+ # Keep tick_state in sync with new separate keys + legacy combined flags
3748
+ if side == 'top' and key == 'ticks':
3749
+ tick_state['t_ticks'] = bool(wasd['top']['ticks'])
3750
+ tick_state['tx'] = bool(wasd['top']['ticks'] and wasd['top']['labels'])
3751
+ if side == 'top' and key == 'labels':
3752
+ tick_state['t_labels'] = bool(wasd['top']['labels'])
3753
+ tick_state['tx'] = bool(wasd['top']['ticks'] and wasd['top']['labels'])
3754
+ if side == 'bottom' and key == 'ticks':
3755
+ tick_state['b_ticks'] = bool(wasd['bottom']['ticks'])
3756
+ tick_state['bx'] = bool(wasd['bottom']['ticks'] and wasd['bottom']['labels'])
3757
+ if side == 'bottom' and key == 'labels':
3758
+ tick_state['b_labels'] = bool(wasd['bottom']['labels'])
3759
+ tick_state['bx'] = bool(wasd['bottom']['ticks'] and wasd['bottom']['labels'])
3760
+ if side == 'left' and key == 'ticks':
3761
+ tick_state['l_ticks'] = bool(wasd['left']['ticks'])
3762
+ tick_state['ly'] = bool(wasd['left']['ticks'] and wasd['left']['labels'])
3763
+ if side == 'left' and key == 'labels':
3764
+ tick_state['l_labels'] = bool(wasd['left']['labels'])
3765
+ tick_state['ly'] = bool(wasd['left']['ticks'] and wasd['left']['labels'])
3766
+ if side == 'right' and key == 'ticks':
3767
+ tick_state['r_ticks'] = bool(wasd['right']['ticks'])
3768
+ tick_state['ry'] = bool(wasd['right']['ticks'] and wasd['right']['labels'])
3769
+ if side == 'right' and key == 'labels':
3770
+ tick_state['r_labels'] = bool(wasd['right']['labels'])
3771
+ tick_state['ry'] = bool(wasd['right']['ticks'] and wasd['right']['labels'])
3772
+ if side == 'top' and key == 'minor':
3773
+ tick_state['mtx'] = bool(wasd['top']['minor'])
3774
+ if side == 'bottom' and key == 'minor':
3775
+ tick_state['mbx'] = bool(wasd['bottom']['minor'])
3776
+ if side == 'left' and key == 'minor':
3777
+ tick_state['mly'] = bool(wasd['left']['minor'])
3778
+ if side == 'right' and key == 'minor':
3779
+ tick_state['mry'] = bool(wasd['right']['minor'])
3780
+ if changed:
3781
+ push_state("wasd-toggle")
3782
+ _apply_wasd(changed_sides if changed_sides else None)
3783
+ # Single draw at the end after all positioning is complete
3784
+ try:
3785
+ fig.canvas.draw()
3786
+ except Exception:
3787
+ fig.canvas.draw_idle()
3788
+ except Exception as e:
3789
+ print(f"Error in WASD tick menu: {e}")
3790
+ _print_menu(); continue
3791
+ elif key == 'g':
3792
+ while True:
3793
+ print("Geometry: p=plot frame, c=canvas, q=back")
3794
+ sub = _safe_input("Geom> ").strip().lower()
3795
+ if not sub:
3796
+ continue
3797
+ if sub == 'q':
3798
+ break
3799
+ if sub == 'p':
3800
+ # We don't have y_data_list/labels; we just trigger a redraw after resize
3801
+ try:
3802
+ push_state("resize-frame")
3803
+ resize_plot_frame(fig, ax, [], [], type('Args', (), {'stack': False})(), lambda *_: None)
3804
+ except Exception as e:
3805
+ print(f"Resize failed: {e}")
3806
+ elif sub == 'c':
3807
+ try:
3808
+ push_state("resize-canvas")
3809
+ resize_canvas(fig, ax)
3810
+ except Exception as e:
3811
+ print(f"Resize failed: {e}")
3812
+ _print_menu(); continue
3813
+ elif key == 'r':
3814
+ # Rename axis titles
3815
+ print("Tip: Use LaTeX/mathtext for special characters:")
3816
+ print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
3817
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3818
+ print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
3819
+ while True:
3820
+ print("Rename: x=x-axis, ly=left y-axis, ry=right y-axis, l=legend labels, q=back")
3821
+ sub = _safe_input("Rename> ").strip().lower()
3822
+ if not sub:
3823
+ continue
3824
+ if sub == 'q':
3825
+ break
3826
+ if sub == 'l':
3827
+ # Rename legend labels (file name in legend)
3828
+ if not is_multi_file:
3829
+ # Single file mode: rename the default file
3830
+ current_file = file_data[0]
3831
+ sc_chg = current_file['sc_charge']
3832
+ sc_dchg = current_file['sc_discharge']
3833
+ sc_eff = current_file['sc_eff']
3834
+
3835
+ # Get current labels
3836
+ chg_label = sc_chg.get_label() or ''
3837
+ dchg_label = sc_dchg.get_label() or ''
3838
+ eff_label = sc_eff.get_label() or ''
3839
+
3840
+ # Extract base filename (everything before " charge", " discharge", or " efficiency")
3841
+ # Also handle patterns like "filename (Chg)", "filename (Dchg)", "filename (Eff)"
3842
+ base_name = current_file.get('filename', 'Data')
3843
+
3844
+ # Try to extract from labels
3845
+ import re
3846
+ for label in [chg_label, dchg_label, eff_label]:
3847
+ if label:
3848
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3849
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3850
+ if bracket_match:
3851
+ potential_base = bracket_match.group(1).strip()
3852
+ if potential_base:
3853
+ base_name = potential_base
3854
+ break
3855
+ else:
3856
+ # Try to extract from text suffix patterns
3857
+ for suffix in [' charge', ' discharge', ' efficiency']:
3858
+ if label.endswith(suffix):
3859
+ potential_base = label[:-len(suffix)].strip()
3860
+ if potential_base:
3861
+ base_name = potential_base
3862
+ break
3863
+ if base_name != current_file.get('filename', 'Data'):
3864
+ break
3865
+
3866
+ print(f"Current file name in legend: '{base_name}'")
3867
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3868
+ if new_name and new_name.lower() != 'q':
3869
+ new_name = convert_label_shortcuts(new_name)
3870
+ try:
3871
+ push_state("rename-legend")
3872
+
3873
+ # Extract bracket content from original labels if present
3874
+ import re
3875
+ chg_bracket = ''
3876
+ dchg_bracket = ''
3877
+ eff_bracket = ''
3878
+
3879
+ # Check for bracket patterns in original labels
3880
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3881
+ if chg_match:
3882
+ chg_bracket = chg_match.group(1)
3883
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3884
+ if dchg_match:
3885
+ dchg_bracket = dchg_match.group(1)
3886
+ # Fix capitalization: Dchg -> DChg
3887
+ if dchg_bracket.lower() == 'dchg':
3888
+ dchg_bracket = 'DChg'
3889
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3890
+ if eff_match:
3891
+ eff_bracket = eff_match.group(1)
3892
+
3893
+ # If no brackets found, extract from label suffix or use defaults
3894
+ if not chg_bracket:
3895
+ # Try to extract from " charge" suffix
3896
+ if chg_label.endswith(' charge'):
3897
+ chg_bracket = 'Chg'
3898
+ else:
3899
+ chg_bracket = 'Chg'
3900
+ if not dchg_bracket:
3901
+ # Try to extract from " discharge" suffix
3902
+ if dchg_label.endswith(' discharge'):
3903
+ dchg_bracket = 'DChg'
3904
+ else:
3905
+ dchg_bracket = 'DChg'
3906
+ if not eff_bracket:
3907
+ # Try to extract from " efficiency" suffix
3908
+ if eff_label.endswith(' efficiency'):
3909
+ eff_bracket = 'Eff'
3910
+ else:
3911
+ eff_bracket = 'Eff'
3912
+
3913
+ # Build new labels with brackets preserved
3914
+ new_chg_label = f"{new_name} ({chg_bracket})"
3915
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
3916
+ new_eff_label = f"{new_name} ({eff_bracket})"
3917
+
3918
+ # Update labels
3919
+ sc_chg.set_label(new_chg_label)
3920
+ sc_dchg.set_label(new_dchg_label)
3921
+ sc_eff.set_label(new_eff_label)
3922
+
3923
+ # Update filename in file_data
3924
+ current_file['filename'] = new_name
3925
+
3926
+ # Rebuild legend (preserve position)
3927
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
3928
+ fig.canvas.draw_idle()
3929
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
3930
+ except Exception as e:
3931
+ print(f"Error: {e}")
3932
+ else:
3933
+ # Multi-file mode: show file list and let user select
3934
+ print("\nAvailable files:")
3935
+ _print_file_list(file_data, current_file_idx)
3936
+ file_choice = _safe_input("Enter file number to rename (q=cancel): ").strip()
3937
+ if file_choice and file_choice.lower() != 'q':
3938
+ try:
3939
+ file_idx = int(file_choice) - 1
3940
+ if 0 <= file_idx < len(file_data):
3941
+ current_file = file_data[file_idx]
3942
+ sc_chg = current_file['sc_charge']
3943
+ sc_dchg = current_file['sc_discharge']
3944
+ sc_eff = current_file['sc_eff']
3945
+
3946
+ # Get current labels
3947
+ chg_label = sc_chg.get_label() or ''
3948
+ dchg_label = sc_dchg.get_label() or ''
3949
+ eff_label = sc_eff.get_label() or ''
3950
+
3951
+ # Extract base filename
3952
+ base_name = current_file.get('filename', 'Data')
3953
+ import re
3954
+ for label in [chg_label, dchg_label, eff_label]:
3955
+ if label:
3956
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3957
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3958
+ if bracket_match:
3959
+ potential_base = bracket_match.group(1).strip()
3960
+ if potential_base:
3961
+ base_name = potential_base
3962
+ break
3963
+ else:
3964
+ # Try to extract from text suffix patterns
3965
+ for suffix in [' charge', ' discharge', ' efficiency']:
3966
+ if label.endswith(suffix):
3967
+ potential_base = label[:-len(suffix)].strip()
3968
+ if potential_base:
3969
+ base_name = potential_base
3970
+ break
3971
+ if base_name != current_file.get('filename', 'Data'):
3972
+ break
3973
+
3974
+ print(f"Current file name in legend: '{base_name}'")
3975
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3976
+ if new_name and new_name.lower() != 'q':
3977
+ new_name = convert_label_shortcuts(new_name)
3978
+ try:
3979
+ push_state("rename-legend")
3980
+
3981
+ # Extract bracket content from original labels if present
3982
+ import re
3983
+ chg_bracket = ''
3984
+ dchg_bracket = ''
3985
+ eff_bracket = ''
3986
+
3987
+ # Check for bracket patterns in original labels
3988
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3989
+ if chg_match:
3990
+ chg_bracket = chg_match.group(1)
3991
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3992
+ if dchg_match:
3993
+ dchg_bracket = dchg_match.group(1)
3994
+ # Fix capitalization: Dchg -> DChg
3995
+ if dchg_bracket.lower() == 'dchg':
3996
+ dchg_bracket = 'DChg'
3997
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3998
+ if eff_match:
3999
+ eff_bracket = eff_match.group(1)
4000
+
4001
+ # If no brackets found, extract from label suffix or use defaults
4002
+ if not chg_bracket:
4003
+ # Try to extract from " charge" suffix
4004
+ if chg_label.endswith(' charge'):
4005
+ chg_bracket = 'Chg'
4006
+ else:
4007
+ chg_bracket = 'Chg'
4008
+ if not dchg_bracket:
4009
+ # Try to extract from " discharge" suffix
4010
+ if dchg_label.endswith(' discharge'):
4011
+ dchg_bracket = 'DChg'
4012
+ else:
4013
+ dchg_bracket = 'DChg'
4014
+ if not eff_bracket:
4015
+ # Try to extract from " efficiency" suffix
4016
+ if eff_label.endswith(' efficiency'):
4017
+ eff_bracket = 'Eff'
4018
+ else:
4019
+ eff_bracket = 'Eff'
4020
+
4021
+ # Build new labels with brackets preserved
4022
+ new_chg_label = f"{new_name} ({chg_bracket})"
4023
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
4024
+ new_eff_label = f"{new_name} ({eff_bracket})"
4025
+
4026
+ # Update labels
4027
+ sc_chg.set_label(new_chg_label)
4028
+ sc_dchg.set_label(new_dchg_label)
4029
+ sc_eff.set_label(new_eff_label)
4030
+
4031
+ # Update filename in file_data
4032
+ current_file['filename'] = new_name
4033
+
4034
+ # Rebuild legend (preserve position)
4035
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
4036
+ fig.canvas.draw_idle()
4037
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
4038
+ except Exception as e:
4039
+ print(f"Error: {e}")
4040
+ else:
4041
+ print("Invalid file number.")
4042
+ except (ValueError, KeyboardInterrupt):
4043
+ print("Invalid input.")
4044
+ elif sub == 'x':
4045
+ current = ax.get_xlabel()
4046
+ print(f"Current x-axis title: '{current}'")
4047
+ new_title = _safe_input("Enter new x-axis title (q=cancel): ")
4048
+ if new_title and new_title.lower() != 'q':
4049
+ new_title = convert_label_shortcuts(new_title)
4050
+ try:
4051
+ push_state("rename-x")
4052
+ ax.set_xlabel(new_title)
4053
+ # Update stored titles for top/bottom
4054
+ ax._stored_xlabel = new_title
4055
+ ax._stored_top_xlabel = new_title
4056
+ # If top title is visible, update it
4057
+ if hasattr(ax, '_top_xlabel_text') and ax._top_xlabel_text is not None:
4058
+ if ax._top_xlabel_text.get_visible():
4059
+ ax._top_xlabel_text.set_text(new_title)
4060
+ fig.canvas.draw_idle()
4061
+ print(f"X-axis title updated to: '{new_title}'")
4062
+ except Exception as e:
4063
+ print(f"Error: {e}")
4064
+ elif sub == 'ly':
4065
+ current = ax.get_ylabel()
4066
+ print(f"Current left y-axis title: '{current}'")
4067
+ new_title = _safe_input("Enter new left y-axis title (q=cancel): ")
4068
+ if new_title and new_title.lower() != 'q':
4069
+ new_title = convert_label_shortcuts(new_title)
4070
+ try:
4071
+ push_state("rename-ly")
4072
+ ax.set_ylabel(new_title)
4073
+ # Update stored title
4074
+ ax._stored_ylabel = new_title
4075
+ fig.canvas.draw_idle()
4076
+ print(f"Left y-axis title updated to: '{new_title}'")
4077
+ except Exception as e:
4078
+ print(f"Error: {e}")
4079
+ elif sub == 'ry':
4080
+ current = ax2.get_ylabel()
4081
+ print(f"Current right y-axis title: '{current}'")
4082
+ new_title = _safe_input("Enter new right y-axis title (q=cancel): ")
4083
+ if new_title and new_title.lower() != 'q':
4084
+ new_title = convert_label_shortcuts(new_title)
4085
+ try:
4086
+ push_state("rename-ry")
4087
+ ax2.set_ylabel(new_title)
4088
+ # Update stored title
4089
+ if not hasattr(ax2, '_stored_ylabel'):
4090
+ ax2._stored_ylabel = ''
4091
+ ax2._stored_ylabel = new_title
4092
+ fig.canvas.draw_idle()
4093
+ print(f"Right y-axis title updated to: '{new_title}'")
4094
+ except Exception as e:
4095
+ print(f"Error: {e}")
4096
+ else:
4097
+ print("Unknown option.")
4098
+ _print_menu(); continue
4099
+ elif key == 'x':
4100
+ while True:
4101
+ current_xlim = ax.get_xlim()
4102
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
4103
+ rng = _safe_input("Enter x-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4104
+ if not rng or rng.lower() == 'q':
4105
+ break
4106
+ if rng.lower() == 'w':
4107
+ # Upper only: change upper limit, fix lower - stay in loop
4108
+ while True:
4109
+ current_xlim = ax.get_xlim()
4110
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
4111
+ val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
4112
+ if not val or val.lower() == 'q':
4113
+ break
4114
+ try:
4115
+ new_upper = float(val)
4116
+ except (ValueError, KeyboardInterrupt):
4117
+ print("Invalid value, ignored.")
4118
+ continue
4119
+ push_state("x-range")
4120
+ ax.set_xlim(current_xlim[0], new_upper)
4121
+ ax.relim()
4122
+ ax.autoscale_view(scalex=True, scaley=False)
4123
+ # Reapply legend position after axis change to prevent movement
4124
+ try:
4125
+ leg = ax.get_legend()
4126
+ if leg is not None and leg.get_visible():
4127
+ _apply_legend_position()
4128
+ except Exception:
4129
+ pass
4130
+ fig.canvas.draw_idle()
4131
+ print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
4132
+ continue
4133
+ if rng.lower() == 's':
4134
+ # Lower only: change lower limit, fix upper - stay in loop
4135
+ while True:
4136
+ current_xlim = ax.get_xlim()
4137
+ print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
4138
+ val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
4139
+ if not val or val.lower() == 'q':
4140
+ break
4141
+ try:
4142
+ new_lower = float(val)
4143
+ except (ValueError, KeyboardInterrupt):
4144
+ print("Invalid value, ignored.")
4145
+ continue
4146
+ push_state("x-range")
4147
+ ax.set_xlim(new_lower, current_xlim[1])
4148
+ ax.relim()
4149
+ ax.autoscale_view(scalex=True, scaley=False)
4150
+ # Reapply legend position after axis change to prevent movement
4151
+ try:
4152
+ leg = ax.get_legend()
4153
+ if leg is not None and leg.get_visible():
4154
+ _apply_legend_position()
4155
+ except Exception:
4156
+ pass
4157
+ fig.canvas.draw_idle()
4158
+ print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
4159
+ continue
4160
+ if rng.lower() == 'a':
4161
+ # Auto: restore original range from scatter plots
4162
+ push_state("x-range-auto")
4163
+ try:
4164
+ all_x = []
4165
+ for sc in [sc_charge, sc_discharge]:
4166
+ if sc is not None and hasattr(sc, 'get_offsets'):
4167
+ offsets = sc.get_offsets()
4168
+ if offsets.size > 0:
4169
+ all_x.extend([offsets[:, 0].min(), offsets[:, 0].max()])
4170
+ if all_x:
4171
+ orig_min = min(all_x)
4172
+ orig_max = max(all_x)
4173
+ ax.set_xlim(orig_min, orig_max)
4174
+ ax.relim()
4175
+ ax.autoscale_view(scalex=True, scaley=False)
4176
+ fig.canvas.draw_idle()
4177
+ print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
4178
+ else:
4179
+ print("No original data available.")
4180
+ except Exception as e:
4181
+ print(f"Error restoring original X range: {e}")
4182
+ continue
4183
+ parts = rng.replace(',', ' ').split()
4184
+ if len(parts) != 2:
4185
+ print("Need two numbers.")
4186
+ else:
4187
+ try:
4188
+ lo = float(parts[0]); hi = float(parts[1])
4189
+ if lo == hi:
4190
+ print("Min and max cannot be equal.")
4191
+ else:
4192
+ push_state("x-range")
4193
+ ax.set_xlim(min(lo, hi), max(lo, hi))
4194
+ fig.canvas.draw_idle()
4195
+ except Exception:
4196
+ print("Invalid numbers.")
4197
+ _print_menu(); continue
4198
+ elif key == 'y':
4199
+ while True:
4200
+ print("Y-ranges: ly=left axis, ry=right axis, q=back")
4201
+ ycmd = _safe_input("Y> ").strip().lower()
4202
+ if not ycmd:
4203
+ continue
4204
+ if ycmd == 'q':
4205
+ break
4206
+ if ycmd == 'ly':
4207
+ while True:
4208
+ current_ylim = ax.get_ylim()
4209
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4210
+ rng = _safe_input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4211
+ if not rng or rng.lower() == 'q':
4212
+ break
4213
+ if rng.lower() == 'w':
4214
+ # Upper only: change upper limit, fix lower - stay in loop
4215
+ while True:
4216
+ current_ylim = ax.get_ylim()
4217
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4218
+ val = _safe_input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4219
+ if not val or val.lower() == 'q':
4220
+ break
4221
+ try:
4222
+ new_upper = float(val)
4223
+ except (ValueError, KeyboardInterrupt):
4224
+ print("Invalid value, ignored.")
4225
+ continue
4226
+ push_state("y-left-range")
4227
+ ax.set_ylim(current_ylim[0], new_upper)
4228
+ ax.relim()
4229
+ ax.autoscale_view(scalex=False, scaley=True)
4230
+ # Reapply legend position after axis change to prevent movement
4231
+ try:
4232
+ leg = ax.get_legend()
4233
+ if leg is not None and leg.get_visible():
4234
+ _apply_legend_position()
4235
+ except Exception:
4236
+ pass
4237
+ fig.canvas.draw_idle()
4238
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4239
+ continue
4240
+ if rng.lower() == 's':
4241
+ # Lower only: change lower limit, fix upper - stay in loop
4242
+ while True:
4243
+ current_ylim = ax.get_ylim()
4244
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4245
+ val = _safe_input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4246
+ if not val or val.lower() == 'q':
4247
+ break
4248
+ try:
4249
+ new_lower = float(val)
4250
+ except (ValueError, KeyboardInterrupt):
4251
+ print("Invalid value, ignored.")
4252
+ continue
4253
+ push_state("y-left-range")
4254
+ ax.set_ylim(new_lower, current_ylim[1])
4255
+ ax.relim()
4256
+ ax.autoscale_view(scalex=False, scaley=True)
4257
+ # Reapply legend position after axis change to prevent movement
4258
+ try:
4259
+ leg = ax.get_legend()
4260
+ if leg is not None and leg.get_visible():
4261
+ _apply_legend_position()
4262
+ except Exception:
4263
+ pass
4264
+ fig.canvas.draw_idle()
4265
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4266
+ continue
4267
+ if rng.lower() == 'a':
4268
+ # Auto: restore original range from scatter plots
4269
+ push_state("y-left-range-auto")
4270
+ try:
4271
+ all_y = []
4272
+ for sc in [sc_charge, sc_discharge]:
4273
+ if sc is not None and hasattr(sc, 'get_offsets'):
4274
+ offsets = sc.get_offsets()
4275
+ if offsets.size > 0:
4276
+ all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
4277
+ if all_y:
4278
+ orig_min = min(all_y)
4279
+ orig_max = max(all_y)
4280
+ ax.set_ylim(orig_min, orig_max)
4281
+ ax.relim()
4282
+ ax.autoscale_view(scalex=False, scaley=True)
4283
+ fig.canvas.draw_idle()
4284
+ print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4285
+ else:
4286
+ print("No original data available.")
4287
+ except Exception as e:
4288
+ print(f"Error restoring original left Y range: {e}")
4289
+ continue
4290
+ parts = rng.replace(',', ' ').split()
4291
+ if len(parts) != 2:
4292
+ print("Need two numbers."); continue
4293
+ try:
4294
+ lo = float(parts[0]); hi = float(parts[1])
4295
+ if lo == hi:
4296
+ print("Min and max cannot be equal."); continue
4297
+ push_state("y-left-range")
4298
+ ax.set_ylim(min(lo, hi), max(lo, hi))
4299
+ fig.canvas.draw_idle()
4300
+ except Exception:
4301
+ print("Invalid numbers.")
4302
+ elif ycmd == 'ry':
4303
+ while True:
4304
+ try:
4305
+ eff_on = bool(sc_eff.get_visible())
4306
+ except Exception:
4307
+ eff_on = True
4308
+ if not eff_on:
4309
+ print("Right Y is not shown; enable efficiency with 'ry' first.")
4310
+ break
4311
+ current_ylim = ax2.get_ylim()
4312
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4313
+ rng = _safe_input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4314
+ if not rng or rng.lower() == 'q':
4315
+ break
4316
+ if rng.lower() == 'w':
4317
+ # Upper only: change upper limit, fix lower - stay in loop
4318
+ while True:
4319
+ current_ylim = ax2.get_ylim()
4320
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4321
+ val = _safe_input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4322
+ if not val or val.lower() == 'q':
4323
+ break
4324
+ try:
4325
+ new_upper = float(val)
4326
+ except (ValueError, KeyboardInterrupt):
4327
+ print("Invalid value, ignored.")
4328
+ continue
4329
+ push_state("y-right-range")
4330
+ ax2.set_ylim(current_ylim[0], new_upper)
4331
+ ax2.relim()
4332
+ ax2.autoscale_view(scalex=False, scaley=True)
4333
+ # Reapply legend position after axis change to prevent movement
4334
+ try:
4335
+ leg = ax.get_legend()
4336
+ if leg is not None and leg.get_visible():
4337
+ _apply_legend_position()
4338
+ except Exception:
4339
+ pass
4340
+ fig.canvas.draw_idle()
4341
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4342
+ continue
4343
+ if rng.lower() == 's':
4344
+ # Lower only: change lower limit, fix upper - stay in loop
4345
+ while True:
4346
+ current_ylim = ax2.get_ylim()
4347
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4348
+ val = _safe_input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4349
+ if not val or val.lower() == 'q':
4350
+ break
4351
+ try:
4352
+ new_lower = float(val)
4353
+ except (ValueError, KeyboardInterrupt):
4354
+ print("Invalid value, ignored.")
4355
+ continue
4356
+ push_state("y-right-range")
4357
+ ax2.set_ylim(new_lower, current_ylim[1])
4358
+ ax2.relim()
4359
+ ax2.autoscale_view(scalex=False, scaley=True)
4360
+ # Reapply legend position after axis change to prevent movement
4361
+ try:
4362
+ leg = ax.get_legend()
4363
+ if leg is not None and leg.get_visible():
4364
+ _apply_legend_position()
4365
+ except Exception:
4366
+ pass
4367
+ fig.canvas.draw_idle()
4368
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4369
+ continue
4370
+ if rng.lower() == 'a':
4371
+ # Auto: restore original range from efficiency scatter plot
4372
+ push_state("y-right-range-auto")
4373
+ try:
4374
+ if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
4375
+ offsets = sc_eff.get_offsets()
4376
+ if offsets.size > 0:
4377
+ orig_min = float(offsets[:, 1].min())
4378
+ orig_max = float(offsets[:, 1].max())
4379
+ ax2.set_ylim(orig_min, orig_max)
4380
+ ax2.relim()
4381
+ ax2.autoscale_view(scalex=False, scaley=True)
4382
+ fig.canvas.draw_idle()
4383
+ print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4384
+ else:
4385
+ print("No original data available.")
4386
+ else:
4387
+ print("No original data available.")
4388
+ except Exception as e:
4389
+ print(f"Error restoring original right Y range: {e}")
4390
+ continue
4391
+ parts = rng.replace(',', ' ').split()
4392
+ if len(parts) != 2:
4393
+ print("Need two numbers."); continue
4394
+ try:
4395
+ lo = float(parts[0]); hi = float(parts[1])
4396
+ if lo == hi:
4397
+ print("Min and max cannot be equal."); continue
4398
+ push_state("y-right-range")
4399
+ ax2.set_ylim(min(lo, hi), max(lo, hi))
4400
+ fig.canvas.draw_idle()
4401
+ except Exception:
4402
+ print("Invalid numbers.")
4403
+ _print_menu(); continue
4404
+ else:
4405
+ print("Unknown key.")
4406
+ _print_menu(); continue
4407
+
4408
+
4409
+ __all__ = ["cpc_interactive_menu"]