batplot 1.7.24__py3-none-any.whl → 1.7.26__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.

@@ -36,11 +36,45 @@ from __future__ import annotations
36
36
  from typing import Dict, Optional
37
37
  import json
38
38
  import os
39
+ import sys
40
+ import contextlib
41
+ from io import StringIO
39
42
 
40
43
  import matplotlib.pyplot as plt
41
44
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator
42
45
  import random as _random
43
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
+
44
78
  from .ui import (
45
79
  resize_plot_frame, resize_canvas,
46
80
  update_tick_visibility as _ui_update_tick_visibility,
@@ -57,10 +91,20 @@ from .utils import (
57
91
  get_organized_path,
58
92
  )
59
93
  import time
60
- from .color_utils import resolve_color_token
94
+ from .color_utils import resolve_color_token, color_block, palette_preview, manage_user_colors, get_user_color_list, ensure_colormap
61
95
 
62
96
 
63
97
  def _legend_no_frame(ax, *args, **kwargs):
98
+ # Compact legend defaults and labelcolor matching marker/line color
99
+ kwargs.setdefault('frameon', False)
100
+ kwargs.setdefault('handlelength', 1.0)
101
+ kwargs.setdefault('handletextpad', 0.35)
102
+ kwargs.setdefault('labelspacing', 0.25)
103
+ kwargs.setdefault('borderaxespad', 0.5)
104
+ kwargs.setdefault('borderpad', 0.3)
105
+ kwargs.setdefault('columnspacing', 0.6)
106
+ # Let matplotlib color legend text from line/marker colors
107
+ kwargs.setdefault('labelcolor', 'linecolor')
64
108
  leg = ax.legend(*args, **kwargs)
65
109
  if leg is not None:
66
110
  try:
@@ -69,6 +113,27 @@ def _legend_no_frame(ax, *args, **kwargs):
69
113
  pass
70
114
  return leg
71
115
 
116
+
117
+ def _visible_handles_labels(ax, ax2):
118
+ """Return handles/labels for visible artists only."""
119
+ try:
120
+ h1, l1 = ax.get_legend_handles_labels()
121
+ except Exception:
122
+ h1, l1 = [], []
123
+ try:
124
+ h2, l2 = ax2.get_legend_handles_labels()
125
+ except Exception:
126
+ h2, l2 = [], []
127
+ H, L = [], []
128
+ for h, l in list(zip(h1, l1)) + list(zip(h2, l2)):
129
+ try:
130
+ if hasattr(h, 'get_visible') and not h.get_visible():
131
+ continue
132
+ except Exception:
133
+ pass
134
+ H.append(h); L.append(l)
135
+ return H, L
136
+
72
137
  def _colorize_menu(text):
73
138
  """Colorize menu items: command in cyan, colon in white, description in default."""
74
139
  if ':' not in text:
@@ -99,8 +164,8 @@ def _color_of(artist):
99
164
  return None
100
165
 
101
166
 
102
- def _get_legend_title(fig, default: str = "Legend") -> str:
103
- """Fetch stored legend title, falling back to current legend text or default."""
167
+ def _get_legend_title(fig, default: Optional[str] = None) -> Optional[str]:
168
+ """Fetch stored legend title, falling back to current legend text or None."""
104
169
  try:
105
170
  title = getattr(fig, '_cpc_legend_title', None)
106
171
  if isinstance(title, str) and title:
@@ -215,7 +280,7 @@ def _print_menu():
215
280
  " v: show/hide files",
216
281
  ]
217
282
  col2 = [
218
- "r: rename titles",
283
+ "r: rename",
219
284
  "x: x range",
220
285
  "y: y ranges",
221
286
  ]
@@ -262,9 +327,22 @@ def _print_file_list(file_data, current_idx):
262
327
  print()
263
328
 
264
329
 
265
- def _rebuild_legend(ax, ax2, file_data):
266
- """Rebuild legend from all visible files."""
330
+ def _rebuild_legend(ax, ax2, file_data, preserve_position=True):
331
+ """Rebuild legend from all visible files.
332
+
333
+ Args:
334
+ preserve_position: If True, preserve legend position after rebuilding.
335
+ """
267
336
  try:
337
+ fig = ax.figure
338
+ # Get stored position before rebuilding
339
+ xy_in = None
340
+ if preserve_position:
341
+ try:
342
+ xy_in = getattr(fig, '_cpc_legend_xy_in', None)
343
+ except Exception:
344
+ pass
345
+
268
346
  h1, l1 = ax.get_legend_handles_labels()
269
347
  h2, l2 = ax2.get_legend_handles_labels()
270
348
  # Filter to only visible items
@@ -273,8 +351,22 @@ def _rebuild_legend(ax, ax2, file_data):
273
351
  if h.get_visible():
274
352
  h_all.append(h)
275
353
  l_all.append(l)
354
+
276
355
  if h_all:
277
- _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=_get_legend_title(ax.figure))
356
+ # Get legend title (None if not set, to avoid showing "Legend")
357
+ leg_title = _get_legend_title(fig, default=None)
358
+
359
+ if xy_in is not None and preserve_position:
360
+ # Use stored position
361
+ try:
362
+ fw, fh = fig.get_size_inches()
363
+ fx = 0.5 + float(xy_in[0]) / float(fw)
364
+ fy = 0.5 + float(xy_in[1]) / float(fh)
365
+ _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)
366
+ except Exception:
367
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
368
+ else:
369
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
278
370
  else:
279
371
  leg = ax.get_legend()
280
372
  if leg:
@@ -338,24 +430,35 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
338
430
  return None
339
431
  return None
340
432
 
341
- # Current tick visibility (best-effort from axes)
433
+ def _label_visible(lbl):
434
+ try:
435
+ return bool(lbl.get_visible()) and bool(lbl.get_text())
436
+ except Exception:
437
+ return bool(lbl.get_text()) if hasattr(lbl, 'get_text') else False
438
+
439
+ # Current tick visibility (prefer persisted WASD state when available)
342
440
  tick_vis = {
343
- 'bx': True,
344
- 'tx': False,
345
- 'ly': True,
346
- 'ry': True,
347
- 'mbx': False,
348
- 'mtx': False,
349
- 'mly': False,
350
- 'mry': False,
441
+ 'bx': True, 'tx': False, 'ly': True, 'ry': True,
442
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
351
443
  }
352
444
  try:
353
- # Infer from current axes state
354
- import matplotlib as _mpl
355
- tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
356
- tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
357
- tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
358
- tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
445
+ wasd_from_fig = getattr(fig, '_cpc_wasd_state', None)
446
+ if isinstance(wasd_from_fig, dict) and wasd_from_fig:
447
+ # Use stored state (authoritative)
448
+ tick_vis['bx'] = bool(wasd_from_fig.get('bottom', {}).get('labels', True))
449
+ tick_vis['tx'] = bool(wasd_from_fig.get('top', {}).get('labels', False))
450
+ tick_vis['ly'] = bool(wasd_from_fig.get('left', {}).get('labels', True))
451
+ tick_vis['ry'] = bool(wasd_from_fig.get('right', {}).get('labels', True))
452
+ tick_vis['mbx'] = bool(wasd_from_fig.get('bottom', {}).get('minor', False))
453
+ tick_vis['mtx'] = bool(wasd_from_fig.get('top', {}).get('minor', False))
454
+ tick_vis['mly'] = bool(wasd_from_fig.get('left', {}).get('minor', False))
455
+ tick_vis['mry'] = bool(wasd_from_fig.get('right', {}).get('minor', False))
456
+ else:
457
+ # Infer from current axes state
458
+ tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
459
+ tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
460
+ tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
461
+ tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
359
462
  except Exception:
360
463
  pass
361
464
 
@@ -373,36 +476,38 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
373
476
  except Exception:
374
477
  return False
375
478
 
376
- wasd_state = {
377
- 'bottom': {
378
- 'spine': _get_spine_visible(ax, 'bottom'),
379
- 'ticks': bool(tick_vis.get('bx', True)),
380
- 'minor': bool(tick_vis.get('mbx', False)),
381
- 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
382
- 'title': bool(ax.get_xlabel()) # bottom x title
383
- },
384
- 'top': {
385
- 'spine': _get_spine_visible(ax, 'top'),
386
- 'ticks': bool(tick_vis.get('tx', False)),
387
- 'minor': bool(tick_vis.get('mtx', False)),
388
- 'labels': bool(tick_vis.get('tx', False)),
389
- 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
390
- },
391
- 'left': {
392
- 'spine': _get_spine_visible(ax, 'left'),
393
- 'ticks': bool(tick_vis.get('ly', True)),
394
- 'minor': bool(tick_vis.get('mly', False)),
395
- 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
396
- 'title': bool(ax.get_ylabel()) # left y title
397
- },
398
- 'right': {
399
- 'spine': _get_spine_visible(ax2, 'right'),
400
- 'ticks': bool(tick_vis.get('ry', True)),
401
- 'minor': bool(tick_vis.get('mry', False)),
402
- 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
403
- 'title': bool(ax2.get_ylabel()) # right y title
404
- },
405
- }
479
+ wasd_state = getattr(fig, '_cpc_wasd_state', None)
480
+ if not isinstance(wasd_state, dict) or not wasd_state:
481
+ wasd_state = {
482
+ 'bottom': {
483
+ 'spine': _get_spine_visible(ax, 'bottom'),
484
+ 'ticks': bool(tick_vis.get('bx', True)),
485
+ 'minor': bool(tick_vis.get('mbx', False)),
486
+ 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
487
+ 'title': bool(ax.get_xlabel()) # bottom x title
488
+ },
489
+ 'top': {
490
+ 'spine': _get_spine_visible(ax, 'top'),
491
+ 'ticks': bool(tick_vis.get('tx', False)),
492
+ 'minor': bool(tick_vis.get('mtx', False)),
493
+ 'labels': bool(tick_vis.get('tx', False)),
494
+ 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
495
+ },
496
+ 'left': {
497
+ 'spine': _get_spine_visible(ax, 'left'),
498
+ 'ticks': bool(tick_vis.get('ly', True)),
499
+ 'minor': bool(tick_vis.get('mly', False)),
500
+ 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
501
+ 'title': _label_visible(ax.yaxis.label) # left y title
502
+ },
503
+ 'right': {
504
+ 'spine': _get_spine_visible(ax2, 'right'),
505
+ 'ticks': bool(tick_vis.get('ry', True)),
506
+ 'minor': bool(tick_vis.get('mry', False)),
507
+ 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
508
+ 'title': _label_visible(ax2.yaxis.label) # right y title respects visibility
509
+ },
510
+ }
406
511
 
407
512
  # Capture legend state
408
513
  legend_visible = False
@@ -487,16 +592,19 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
487
592
  'series': {
488
593
  'charge': {
489
594
  'color': _color_of(sc_charge),
595
+ 'marker': getattr(sc_charge, 'get_marker', lambda: 'o')(),
490
596
  'markersize': float(getattr(sc_charge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_charge, 'get_sizes') else 32.0,
491
597
  'alpha': float(sc_charge.get_alpha()) if sc_charge.get_alpha() is not None else 1.0,
492
598
  },
493
599
  'discharge': {
494
600
  'color': _color_of(sc_discharge),
601
+ 'marker': getattr(sc_discharge, 'get_marker', lambda: 's')(),
495
602
  'markersize': float(getattr(sc_discharge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_discharge, 'get_sizes') else 32.0,
496
603
  'alpha': float(sc_discharge.get_alpha()) if sc_discharge.get_alpha() is not None else 1.0,
497
604
  },
498
605
  'efficiency': {
499
606
  'color': (sc_eff.get_facecolors()[0].tolist() if hasattr(sc_eff, 'get_facecolors') and len(sc_eff.get_facecolors()) else '#2ca02c'),
607
+ 'marker': getattr(sc_eff, 'get_marker', lambda: '^')(),
500
608
  'markersize': float(getattr(sc_eff, 'get_sizes', lambda: [40])()[0]) if hasattr(sc_eff, 'get_sizes') else 40.0,
501
609
  'alpha': float(sc_eff.get_alpha()) if sc_eff.get_alpha() is not None else 1.0,
502
610
  'visible': bool(getattr(sc_eff, 'get_visible', lambda: True)()),
@@ -508,15 +616,42 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
508
616
  if file_data and isinstance(file_data, list) and len(file_data) > 0:
509
617
  multi_files = []
510
618
  for f in file_data:
619
+ sc_chg = f.get('sc_charge')
620
+ sc_dchg = f.get('sc_discharge')
621
+ sc_eff = f.get('sc_eff')
511
622
  file_info = {
512
623
  'filename': f.get('filename', 'unknown'),
513
624
  'visible': f.get('visible', True),
514
- 'charge_color': _color_of(f.get('sc_charge')),
515
- 'discharge_color': _color_of(f.get('sc_discharge')),
516
- 'efficiency_color': _color_of(f.get('sc_eff')),
625
+ 'charge_color': _color_of(sc_chg),
626
+ 'charge_marker': getattr(sc_chg, 'get_marker', lambda: 'o')() if sc_chg else 'o',
627
+ 'discharge_color': _color_of(sc_dchg),
628
+ 'discharge_marker': getattr(sc_dchg, 'get_marker', lambda: 's')() if sc_dchg else 's',
629
+ 'efficiency_color': _color_of(sc_eff),
630
+ 'efficiency_marker': getattr(sc_eff, 'get_marker', lambda: '^')() if sc_eff else '^',
517
631
  }
632
+ # Save legend labels
633
+ try:
634
+ sc_chg = f.get('sc_charge')
635
+ sc_dchg = f.get('sc_discharge')
636
+ sc_eff = f.get('sc_eff')
637
+ if sc_chg and hasattr(sc_chg, 'get_label'):
638
+ file_info['charge_label'] = sc_chg.get_label() or ''
639
+ if sc_dchg and hasattr(sc_dchg, 'get_label'):
640
+ file_info['discharge_label'] = sc_dchg.get_label() or ''
641
+ if sc_eff and hasattr(sc_eff, 'get_label'):
642
+ file_info['efficiency_label'] = sc_eff.get_label() or ''
643
+ except Exception:
644
+ pass
518
645
  multi_files.append(file_info)
519
646
  cfg['multi_files'] = multi_files
647
+ else:
648
+ # Single file mode: save legend labels
649
+ try:
650
+ cfg['series']['charge']['label'] = sc_charge.get_label() if hasattr(sc_charge, 'get_label') else ''
651
+ cfg['series']['discharge']['label'] = sc_discharge.get_label() if hasattr(sc_discharge, 'get_label') else ''
652
+ cfg['series']['efficiency']['label'] = sc_eff.get_label() if hasattr(sc_eff, 'get_label') else ''
653
+ except Exception:
654
+ pass
520
655
 
521
656
  return cfg
522
657
 
@@ -644,6 +779,13 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
644
779
  # Apply marker sizes and alpha globally to all files in multi-file mode
645
780
  if is_multi_file:
646
781
  for f in file_data:
782
+ # Marker types (global)
783
+ if ch.get('marker') is not None and hasattr(f['sc_charge'], 'set_marker'):
784
+ f['sc_charge'].set_marker(ch['marker'])
785
+ if dh.get('marker') is not None and hasattr(f['sc_discharge'], 'set_marker'):
786
+ f['sc_discharge'].set_marker(dh['marker'])
787
+ if ef.get('marker') is not None and hasattr(f['sc_eff'], 'set_marker'):
788
+ f['sc_eff'].set_marker(ef['marker'])
647
789
  # Marker sizes (global)
648
790
  if ch.get('markersize') is not None and hasattr(f['sc_charge'], 'set_sizes'):
649
791
  f['sc_charge'].set_sizes([float(ch['markersize'])])
@@ -677,6 +819,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
677
819
  if ch:
678
820
  if ch.get('color') is not None:
679
821
  sc_charge.set_color(ch['color'])
822
+ if ch.get('marker') is not None and hasattr(sc_charge, 'set_marker'):
823
+ sc_charge.set_marker(ch['marker'])
680
824
  if ch.get('markersize') is not None and hasattr(sc_charge, 'set_sizes'):
681
825
  sc_charge.set_sizes([float(ch['markersize'])])
682
826
  if ch.get('alpha') is not None:
@@ -684,6 +828,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
684
828
  if dh:
685
829
  if dh.get('color') is not None:
686
830
  sc_discharge.set_color(dh['color'])
831
+ if dh.get('marker') is not None and hasattr(sc_discharge, 'set_marker'):
832
+ sc_discharge.set_marker(dh['marker'])
687
833
  if dh.get('markersize') is not None and hasattr(sc_discharge, 'set_sizes'):
688
834
  sc_discharge.set_sizes([float(dh['markersize'])])
689
835
  if dh.get('alpha') is not None:
@@ -694,6 +840,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
694
840
  sc_eff.set_color(ef['color'])
695
841
  except Exception:
696
842
  pass
843
+ if ef.get('marker') is not None and hasattr(sc_eff, 'set_marker'):
844
+ sc_eff.set_marker(ef['marker'])
697
845
  if ef.get('markersize') is not None and hasattr(sc_eff, 'set_sizes'):
698
846
  sc_eff.set_sizes([float(ef['markersize'])])
699
847
  if ef.get('alpha') is not None:
@@ -721,6 +869,27 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
721
869
  leg.set_visible(leg_visible)
722
870
  if leg_visible:
723
871
  _apply_legend_position()
872
+ # Re-apply legend label colors to match handles after position/visibility changes
873
+ try:
874
+ leg = ax.get_legend()
875
+ if leg is not None:
876
+ handles = list(getattr(leg, "legendHandles", []))
877
+ for h, txt in zip(handles, leg.get_texts()):
878
+ col = _color_of(h)
879
+ if col is None and hasattr(h, 'get_edgecolor'):
880
+ col = h.get_edgecolor()
881
+ if isinstance(col, (list, tuple)) and len(col) and not isinstance(col, str):
882
+ col = col[0]
883
+ try:
884
+ import numpy as _np
885
+ if hasattr(col, "__len__") and not isinstance(col, str):
886
+ col = tuple(_np.array(col).ravel().tolist())
887
+ except Exception:
888
+ pass
889
+ if col is not None:
890
+ txt.set_color(col)
891
+ except Exception:
892
+ pass
724
893
  except Exception:
725
894
  pass
726
895
  # Apply tick visibility/widths and spines
@@ -728,6 +897,11 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
728
897
  tk = cfg.get('ticks', {})
729
898
  # Try wasd_state first (version 2), fall back to visibility dict (version 1)
730
899
  wasd = cfg.get('wasd_state', {})
900
+ if isinstance(wasd, dict) and wasd:
901
+ try:
902
+ setattr(fig, '_cpc_wasd_state', wasd)
903
+ except Exception:
904
+ pass
731
905
  if wasd:
732
906
  # Use WASD state (20 parameters)
733
907
  bx = bool(wasd.get('bottom', {}).get('labels', True))
@@ -754,6 +928,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
754
928
  ax.tick_params(axis='x', bottom=bx, labelbottom=bx, top=tx, labeltop=tx)
755
929
  ax.tick_params(axis='y', left=ly, labelleft=ly)
756
930
  ax2.tick_params(axis='y', right=ry, labelright=ry)
931
+ try:
932
+ ax.xaxis.label.set_visible(bool(wasd.get('bottom', {}).get('title', True)) if wasd else bx)
933
+ ax.yaxis.label.set_visible(bool(wasd.get('left', {}).get('title', True)) if wasd else ly)
934
+ ax2.yaxis.label.set_visible(bool(wasd.get('right', {}).get('title', True)) if wasd else ry)
935
+ except Exception:
936
+ pass
757
937
  # Minor ticks
758
938
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator, NullLocator
759
939
  if mbx or mtx:
@@ -949,6 +1129,94 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
949
1129
  _ui_position_right_ylabel(ax2, fig, tick_state)
950
1130
  except Exception:
951
1131
  pass
1132
+ # Restore legend labels
1133
+ try:
1134
+ if is_multi_file and file_data:
1135
+ multi_files = cfg.get('multi_files', [])
1136
+ if multi_files and len(multi_files) == len(file_data):
1137
+ for i, f_info in enumerate(multi_files):
1138
+ if i < len(file_data):
1139
+ f = file_data[i]
1140
+ # Restore colors FIRST (before labels)
1141
+ if 'charge_color' in f_info and f.get('sc_charge'):
1142
+ try:
1143
+ col = f_info['charge_color']
1144
+ f['sc_charge'].set_color(col)
1145
+ f['color'] = col
1146
+ # Force update of facecolors for scatter plots
1147
+ if hasattr(f['sc_charge'], 'set_facecolors'):
1148
+ from matplotlib.colors import to_rgba
1149
+ rgba = to_rgba(col)
1150
+ f['sc_charge'].set_facecolors(rgba)
1151
+ except Exception:
1152
+ pass
1153
+ if 'discharge_color' in f_info and f.get('sc_discharge'):
1154
+ try:
1155
+ col = f_info['discharge_color']
1156
+ f['sc_discharge'].set_color(col)
1157
+ # Force update of facecolors for scatter plots
1158
+ if hasattr(f['sc_discharge'], 'set_facecolors'):
1159
+ from matplotlib.colors import to_rgba
1160
+ rgba = to_rgba(col)
1161
+ f['sc_discharge'].set_facecolors(rgba)
1162
+ except Exception:
1163
+ pass
1164
+ if 'efficiency_color' in f_info and f.get('sc_eff'):
1165
+ try:
1166
+ col = f_info['efficiency_color']
1167
+ f['sc_eff'].set_color(col)
1168
+ f['eff_color'] = col
1169
+ # Force update of facecolors for scatter plots
1170
+ if hasattr(f['sc_eff'], 'set_facecolors'):
1171
+ from matplotlib.colors import to_rgba
1172
+ rgba = to_rgba(col)
1173
+ f['sc_eff'].set_facecolors(rgba)
1174
+ except Exception:
1175
+ pass
1176
+ # Restore legend labels
1177
+ if 'charge_label' in f_info and f.get('sc_charge'):
1178
+ try:
1179
+ f['sc_charge'].set_label(f_info['charge_label'])
1180
+ except Exception:
1181
+ pass
1182
+ if 'discharge_label' in f_info and f.get('sc_discharge'):
1183
+ try:
1184
+ f['sc_discharge'].set_label(f_info['discharge_label'])
1185
+ except Exception:
1186
+ pass
1187
+ if 'efficiency_label' in f_info and f.get('sc_eff'):
1188
+ try:
1189
+ f['sc_eff'].set_label(f_info['efficiency_label'])
1190
+ except Exception:
1191
+ pass
1192
+ # Update filename if present
1193
+ if 'filename' in f_info:
1194
+ f['filename'] = f_info['filename']
1195
+ else:
1196
+ # Single file mode: restore legend labels
1197
+ s = cfg.get('series', {})
1198
+ ch = s.get('charge', {})
1199
+ dh = s.get('discharge', {})
1200
+ ef = s.get('efficiency', {})
1201
+ if 'label' in ch and hasattr(sc_charge, 'set_label'):
1202
+ try:
1203
+ sc_charge.set_label(ch['label'])
1204
+ except Exception:
1205
+ pass
1206
+ if 'label' in dh and hasattr(sc_discharge, 'set_label'):
1207
+ try:
1208
+ sc_discharge.set_label(dh['label'])
1209
+ except Exception:
1210
+ pass
1211
+ if 'label' in ef and hasattr(sc_eff, 'set_label'):
1212
+ try:
1213
+ sc_eff.set_label(ef['label'])
1214
+ except Exception:
1215
+ pass
1216
+ # Rebuild legend after restoring labels
1217
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1218
+ except Exception:
1219
+ pass
952
1220
  try:
953
1221
  fig.canvas.draw_idle()
954
1222
  except Exception:
@@ -1061,8 +1329,22 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1061
1329
  if file_data is None:
1062
1330
  # Backward compatibility: create file_data structure from single file
1063
1331
  # This allows the function to work with old code that passes individual artists
1332
+ # Try to get filename from label if available
1333
+ filename = 'Data'
1334
+ try:
1335
+ if hasattr(sc_charge, 'get_label') and sc_charge.get_label():
1336
+ label = sc_charge.get_label()
1337
+ # Extract filename from label like "filename (Chg)" or use label as-is
1338
+ if ' (Chg)' in label:
1339
+ filename = label.replace(' (Chg)', '')
1340
+ elif ' (Dch)' in label:
1341
+ filename = label.replace(' (Dch)', '')
1342
+ elif label and label != 'Charge capacity':
1343
+ filename = label
1344
+ except Exception:
1345
+ pass
1064
1346
  file_data = [{
1065
- 'filename': 'Data', # Default filename
1347
+ 'filename': filename,
1066
1348
  'sc_charge': sc_charge, # Charge capacity scatter artist
1067
1349
  'sc_discharge': sc_discharge, # Discharge capacity scatter artist
1068
1350
  'sc_eff': sc_eff, # Efficiency scatter artist
@@ -1105,6 +1387,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1105
1387
  'mly': False, # minor left y-axis ticks - hidden by default
1106
1388
  'mry': False, # minor right y-axis ticks - hidden by default
1107
1389
  }
1390
+ try:
1391
+ saved_wasd = getattr(fig, '_cpc_wasd_state', None)
1392
+ if isinstance(saved_wasd, dict) and saved_wasd:
1393
+ tick_state['bx'] = bool(saved_wasd.get('bottom', {}).get('labels', tick_state['bx']))
1394
+ tick_state['tx'] = bool(saved_wasd.get('top', {}).get('labels', tick_state['tx']))
1395
+ tick_state['ly'] = bool(saved_wasd.get('left', {}).get('labels', tick_state['ly']))
1396
+ tick_state['ry'] = bool(saved_wasd.get('right', {}).get('labels', tick_state['ry']))
1397
+ tick_state['mbx'] = bool(saved_wasd.get('bottom', {}).get('minor', tick_state['mbx']))
1398
+ tick_state['mtx'] = bool(saved_wasd.get('top', {}).get('minor', tick_state['mtx']))
1399
+ tick_state['mly'] = bool(saved_wasd.get('left', {}).get('minor', tick_state['mly']))
1400
+ tick_state['mry'] = bool(saved_wasd.get('right', {}).get('minor', tick_state['mry']))
1401
+ except Exception:
1402
+ pass
1108
1403
 
1109
1404
  # --- Undo stack using style snapshots ---
1110
1405
  state_history = [] # list of cfg dicts
@@ -1169,6 +1464,27 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1169
1464
  if k in tick_state:
1170
1465
  tick_state[k] = bool(v)
1171
1466
  _update_ticks()
1467
+ # Re-apply legend text colors after state restore (undo)
1468
+ try:
1469
+ leg = ax.get_legend()
1470
+ if leg is not None:
1471
+ handles = list(getattr(leg, "legendHandles", []))
1472
+ for h, txt in zip(handles, leg.get_texts()):
1473
+ col = _color_of(h)
1474
+ if col is None and hasattr(h, 'get_edgecolor'):
1475
+ col = h.get_edgecolor()
1476
+ if isinstance(col, (list, tuple)) and len(col) and not isinstance(col, str):
1477
+ col = col[0]
1478
+ try:
1479
+ import numpy as _np
1480
+ if hasattr(col, "__len__") and not isinstance(col, str):
1481
+ col = tuple(_np.array(col).ravel().tolist())
1482
+ except Exception:
1483
+ pass
1484
+ if col is not None:
1485
+ txt.set_color(col)
1486
+ except Exception:
1487
+ pass
1172
1488
  try:
1173
1489
  fig.canvas.draw()
1174
1490
  except Exception:
@@ -1181,6 +1497,8 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1181
1497
  try:
1182
1498
  # Apply shared visibility to primary ax; then adjust twin for right side
1183
1499
  _ui_update_tick_visibility(ax, tick_state)
1500
+ # Ensure left axis ticks/labels don't appear on right axis
1501
+ ax.tick_params(axis='y', right=False, labelright=False)
1184
1502
  # Right axis tick params follow r_* keys
1185
1503
  ax2.tick_params(axis='y',
1186
1504
  right=tick_state.get('r_ticks', tick_state.get('ry', False)),
@@ -1239,7 +1557,6 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1239
1557
  """Reapply legend position using stored inches offset relative to canvas center."""
1240
1558
  try:
1241
1559
  xy_in = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
1242
- leg = ax.get_legend()
1243
1560
  if xy_in is None:
1244
1561
  return
1245
1562
  # Compute figure-fraction anchor from inches
@@ -1248,14 +1565,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1248
1565
  return
1249
1566
  fx = 0.5 + float(xy_in[0]) / float(fw)
1250
1567
  fy = 0.5 + float(xy_in[1]) / float(fh)
1251
- # Use current handles/labels
1252
- h1, l1 = ax.get_legend_handles_labels()
1253
- h2, l2 = ax2.get_legend_handles_labels()
1254
- if h1 or h2:
1568
+ # Use current visible handles/labels
1569
+ H, L = _visible_handles_labels(ax, ax2)
1570
+ if H:
1255
1571
  _legend_no_frame(
1256
1572
  ax,
1257
- h1 + h2,
1258
- l1 + l2,
1573
+ H,
1574
+ L,
1259
1575
  loc='center',
1260
1576
  bbox_to_anchor=(fx, fy),
1261
1577
  bbox_transform=fig.transFigure,
@@ -1279,16 +1595,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1279
1595
  pass
1280
1596
 
1281
1597
  _print_menu()
1282
- if is_multi_file:
1283
- print(f"\n[Multi-file mode: {len(file_data)} files loaded]")
1284
- _print_file_list(file_data, current_file_idx)
1285
1598
 
1286
1599
  while True:
1287
1600
  try:
1288
1601
  # Update current file's scatter artists for commands that need them
1289
1602
  sc_charge, sc_discharge, sc_eff = _get_current_file_artists(file_data, current_file_idx)
1290
1603
 
1291
- key = input("Press a key: ").strip().lower()
1604
+ key = _safe_input("Press a key: ").strip().lower()
1292
1605
  except (KeyboardInterrupt, EOFError):
1293
1606
  print("\n\nExiting interactive menu...")
1294
1607
  break
@@ -1300,7 +1613,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1300
1613
  try:
1301
1614
  if is_multi_file:
1302
1615
  _print_file_list(file_data, current_file_idx)
1303
- choice = input(f"Toggle visibility for file (1-{len(file_data)}), 'a' for all, or q=cancel: ").strip()
1616
+ choice = _safe_input(f"Toggle visibility for file (1-{len(file_data)}), 'a' for all, or q=cancel: ").strip()
1304
1617
  if choice.lower() == 'q':
1305
1618
  _print_menu()
1306
1619
  _print_file_list(file_data, current_file_idx)
@@ -1350,7 +1663,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1350
1663
 
1351
1664
  if key == 'q':
1352
1665
  try:
1353
- confirm = input(_colorize_prompt("Quit CPC interactive? Remember to save! Quit now? (y/n): ")).strip().lower()
1666
+ confirm = _safe_input(_colorize_prompt("Quit CPC interactive? Remember to save! Quit now? (y/n): ")).strip().lower()
1354
1667
  except Exception:
1355
1668
  confirm = 'y'
1356
1669
  if confirm == 'y':
@@ -1361,283 +1674,282 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1361
1674
  restore_state()
1362
1675
  _print_menu(); continue
1363
1676
  elif key == 'c':
1364
- # Colors submenu: ly (left Y series) and ry (right Y efficiency)
1677
+ # Colors submenu: ly (left Y series) and ry (right Y efficiency), with user colors and palettes
1365
1678
  try:
1679
+ # Use same palettes as EC interactive
1680
+ palette_opts = ['tab10', 'Set2', 'Dark2', 'viridis', 'plasma']
1681
+ def _palette_color(name, idx=0, total=1, default_val=0.4):
1682
+ import matplotlib.cm as cm
1683
+ import matplotlib.colors as mcolors
1684
+ import numpy as _np
1685
+ # Ensure colormap is registered before use
1686
+ if not ensure_colormap(name):
1687
+ # Fallback to viridis if colormap can't be registered
1688
+ name = 'viridis'
1689
+ ensure_colormap(name)
1690
+ try:
1691
+ cmap = cm.get_cmap(name)
1692
+ except Exception:
1693
+ # Fallback if get_cmap fails
1694
+ ensure_colormap('viridis')
1695
+ cmap = cm.get_cmap('viridis')
1696
+
1697
+ # Special handling for tab10 to match hardcoded colors exactly
1698
+ if name.lower() == 'tab10':
1699
+ default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
1700
+ '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
1701
+ return default_tab10_colors[idx % len(default_tab10_colors)]
1702
+
1703
+ # For discrete colormaps (Set2, Dark2), access colors directly
1704
+ if hasattr(cmap, 'colors') and cmap.colors is not None:
1705
+ # Discrete colormap: access colors directly by index
1706
+ colors = cmap.colors
1707
+ rgb = colors[idx % len(colors)]
1708
+ if isinstance(rgb, tuple) and len(rgb) >= 3:
1709
+ return mcolors.rgb2hex(rgb[:3])
1710
+
1711
+ # For continuous colormaps (viridis, plasma), sample evenly
1712
+ if total == 1:
1713
+ vals = [0.55]
1714
+ elif total == 2:
1715
+ vals = [0.15, 0.85]
1716
+ else:
1717
+ vals = _np.linspace(0.08, 0.88, total)
1718
+ rgb = cmap(vals[idx % len(vals)])
1719
+ return mcolors.rgb2hex(rgb[:3])
1720
+ def _resolve_color(spec, idx=0, total=1, default_cmap='tab10'):
1721
+ spec = spec.strip()
1722
+ if not spec:
1723
+ return None
1724
+ if spec.lower() == 'r':
1725
+ return _palette_color(default_cmap, idx, total, 0.4)
1726
+ # user colors: u# or plain number referencing saved list
1727
+ uc = None
1728
+ if spec.lower().startswith('u') and len(spec) > 1 and spec[1:].isdigit():
1729
+ uc = resolve_color_token(spec, fig)
1730
+ elif spec.isdigit():
1731
+ # number as palette index if within palette list
1732
+ n = int(spec)
1733
+ if 1 <= n <= len(palette_opts):
1734
+ palette_name = palette_opts[n-1]
1735
+ return _palette_color(palette_name, idx, total, 0.4)
1736
+ if uc:
1737
+ return uc
1738
+ # Check if spec is a palette name (case-insensitive)
1739
+ spec_lower = spec.lower()
1740
+ base = spec.rstrip('_r').rstrip('_R')
1741
+ base_lower = base.lower()
1742
+ # Check against palette_opts (case-insensitive)
1743
+ for pal in palette_opts:
1744
+ if spec_lower == pal.lower() or base_lower == pal.lower() or spec_lower == (pal + '_r').lower():
1745
+ return _palette_color(pal if not spec.endswith('_r') and not spec.endswith('_R') else spec, idx, total, 0.4)
1746
+ # Fall back to resolve_color_token for hex colors, named colors, etc.
1747
+ return resolve_color_token(spec, fig)
1748
+
1366
1749
  while True:
1367
- print("\nColors: ly=capacity curves, ry=efficiency triangles, q=back")
1368
- sub = input("Colors> ").strip().lower()
1750
+ print("\nColors: ly=capacity curves, ry=efficiency triangles, u=user colors, q=back")
1751
+ sub = _safe_input("Colors> ").strip().lower()
1369
1752
  if not sub:
1370
1753
  continue
1371
1754
  if sub == 'q':
1372
1755
  break
1756
+ if sub == 'u':
1757
+ manage_user_colors(fig); continue
1373
1758
  if sub == 'ly':
1374
- if is_multi_file:
1375
- # Show file list for selection
1376
- print("\nSelect curve to color:")
1377
- for i, f in enumerate(file_data, 1):
1378
- vis_mark = "●" if f.get('visible', True) else "○"
1379
- print(f" {i}. {vis_mark} {f['filename']}")
1380
- choice = input("Enter curve number (1-{}) or 'a' for all, q=cancel: ".format(len(file_data))).strip()
1381
- if not choice or choice.lower() == 'q':
1382
- continue
1383
-
1384
- push_state("colors-ly")
1385
- if choice.lower() == 'a':
1386
- # Apply to all files
1387
- print("\nCharge color palettes (discharge will be auto-generated):")
1388
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1389
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1390
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1391
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1392
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1393
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1394
- if not spec or spec.lower() == 'q':
1759
+ push_state("colors-ly")
1760
+ print("\nCurrent capacity curves:")
1761
+ for i, f in enumerate(file_data, 1):
1762
+ cur = _color_of(f['sc_charge'])
1763
+ vis_mark = "●" if f.get('visible', True) else "○"
1764
+ print(f" {i}. {vis_mark} {f['filename']} {color_block(cur)} {cur}")
1765
+ uc = get_user_color_list(fig)
1766
+ if uc:
1767
+ print("\nSaved colors (refer as number or u#):")
1768
+ for i, c in enumerate(uc, 1):
1769
+ print(f" {i}: {color_block(c)} {c}")
1770
+ print("\nPalettes:")
1771
+ for idx, name in enumerate(palette_opts, 1):
1772
+ bar = palette_preview(name)
1773
+ print(f" {idx}. {name}")
1774
+ if bar:
1775
+ print(f" {bar}")
1776
+ 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()
1777
+ if not color_input or color_input.lower() == 'q':
1778
+ continue
1779
+ tokens = color_input.split()
1780
+ if len(tokens) == 1:
1781
+ # Single token: apply palette to all files
1782
+ spec = tokens[0]
1783
+ for i, f in enumerate(file_data):
1784
+ charge_col = _resolve_color(spec, i, len(file_data), default_cmap='tab10')
1785
+ if not charge_col:
1395
1786
  continue
1396
- for i, f in enumerate(file_data):
1397
- if spec.lower() == 'r':
1398
- # Use Viridis colormap
1399
- import matplotlib.cm as cm
1400
- import matplotlib.colors as mcolors
1401
- viridis = cm.get_cmap('viridis', 10)
1402
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1403
-
1404
- elif spec in ['1', '2', '3', '4', '5']:
1405
- # Use selected palette
1406
- charge_palettes = [
1407
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1408
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1409
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1410
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1411
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1412
- ]
1413
- palette = charge_palettes[int(spec) - 1]
1414
- charge_col = palette[i % len(palette)]
1787
+ discharge_col = _generate_similar_color(charge_col)
1788
+ try:
1789
+ f['sc_charge'].set_color(charge_col)
1790
+ f['sc_discharge'].set_color(discharge_col)
1791
+ f['color'] = charge_col
1792
+ # Force update of facecolors for scatter plots
1793
+ if hasattr(f['sc_charge'], 'set_facecolors'):
1794
+ from matplotlib.colors import to_rgba
1795
+ rgba = to_rgba(charge_col)
1796
+ f['sc_charge'].set_facecolors(rgba)
1797
+ if hasattr(f['sc_discharge'], 'set_facecolors'):
1798
+ from matplotlib.colors import to_rgba
1799
+ rgba = to_rgba(discharge_col)
1800
+ f['sc_discharge'].set_facecolors(rgba)
1801
+ except Exception as e:
1802
+ print(f"Error setting color: {e}")
1803
+ pass
1804
+ else:
1805
+ # Multiple tokens: parse file:color pairs
1806
+ def _apply_manual_entries(tokens):
1807
+ idx_color_pairs = []
1808
+ i = 0
1809
+ while i < len(tokens):
1810
+ tok = tokens[i]
1811
+ if ':' in tok:
1812
+ idx_str, color = tok.split(':', 1)
1415
1813
  else:
1416
- charge_col = spec
1814
+ if i + 1 >= len(tokens):
1815
+ print(f"Skip incomplete entry: {tok}")
1816
+ break
1817
+ idx_str = tok
1818
+ color = tokens[i + 1]
1819
+ i += 1
1820
+ idx_color_pairs.append((idx_str, color))
1821
+ i += 1
1822
+ for idx_str, color in idx_color_pairs:
1823
+ try:
1824
+ file_idx = int(idx_str) - 1
1825
+ except ValueError:
1826
+ print(f"Bad index: {idx_str}")
1827
+ continue
1828
+ if not (0 <= file_idx < len(file_data)):
1829
+ print(f"Index out of range: {idx_str}")
1830
+ continue
1831
+ resolved = resolve_color_token(color, fig)
1832
+ charge_col = resolved if resolved else color
1833
+ if not charge_col:
1834
+ continue
1417
1835
  discharge_col = _generate_similar_color(charge_col)
1418
1836
  try:
1419
- f['sc_charge'].set_color(charge_col)
1420
- f['sc_discharge'].set_color(discharge_col)
1421
- f['color'] = charge_col
1837
+ file_data[file_idx]['sc_charge'].set_color(charge_col)
1838
+ file_data[file_idx]['sc_discharge'].set_color(discharge_col)
1839
+ file_data[file_idx]['color'] = charge_col
1840
+ # Force update of facecolors for scatter plots
1841
+ if hasattr(file_data[file_idx]['sc_charge'], 'set_facecolors'):
1842
+ from matplotlib.colors import to_rgba
1843
+ rgba = to_rgba(charge_col)
1844
+ file_data[file_idx]['sc_charge'].set_facecolors(rgba)
1845
+ if hasattr(file_data[file_idx]['sc_discharge'], 'set_facecolors'):
1846
+ from matplotlib.colors import to_rgba
1847
+ rgba = to_rgba(discharge_col)
1848
+ file_data[file_idx]['sc_discharge'].set_facecolors(rgba)
1422
1849
  except Exception:
1423
1850
  pass
1424
- else:
1425
- # Apply to selected file
1426
- try:
1427
- idx = int(choice) - 1
1428
- if 0 <= idx < len(file_data):
1429
- print("\nCharge color palettes (discharge will be auto-generated):")
1430
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1431
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1432
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1433
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1434
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1435
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1436
- if not spec or spec.lower() == 'q':
1437
- continue
1438
- if spec.lower() == 'r':
1439
- # Use Viridis colormap
1440
- import matplotlib.cm as cm
1441
- import matplotlib.colors as mcolors
1442
- viridis = cm.get_cmap('viridis', 10)
1443
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1444
- elif spec in ['1', '2', '3', '4', '5']:
1445
- # Use selected palette
1446
- charge_palettes = [
1447
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1448
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1449
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1450
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1451
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1452
- ]
1453
- palette = charge_palettes[int(spec) - 1]
1454
- charge_col = palette[0] # Use first color from palette for single file
1455
- else:
1456
- charge_col = spec
1457
- discharge_col = _generate_similar_color(charge_col)
1458
- try:
1459
- file_data[idx]['sc_charge'].set_color(charge_col)
1460
- file_data[idx]['sc_discharge'].set_color(discharge_col)
1461
- file_data[idx]['color'] = charge_col
1462
- except Exception:
1463
- pass
1464
- else:
1465
- print("Invalid file number.")
1466
- except ValueError:
1467
- print("Invalid input.")
1468
- else:
1469
- # Single file mode
1470
- push_state("colors-ly")
1471
- print("\nCharge color palettes (discharge will be auto-generated):")
1472
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1473
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1474
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1475
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1476
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1477
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1478
- if not spec or spec.lower() == 'q':
1479
- continue
1480
- if spec.strip().lower() == 'r':
1481
- # Use Viridis colormap
1482
- import matplotlib.cm as cm
1483
- import matplotlib.colors as mcolors
1484
- viridis = cm.get_cmap('viridis', 10)
1485
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1486
- elif spec in ['1', '2', '3', '4', '5']:
1487
- # Use selected palette
1488
- charge_palettes = [
1489
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1490
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1491
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1492
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1493
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1494
- ]
1495
- palette = charge_palettes[int(spec) - 1]
1496
- charge_col = palette[0] # Use first color from palette
1497
- else:
1498
- charge_col = spec
1499
- discharge_col = _generate_similar_color(charge_col)
1851
+ _apply_manual_entries(tokens)
1852
+ if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1500
1853
  try:
1501
- sc_charge.set_color(charge_col)
1502
- sc_discharge.set_color(discharge_col)
1503
- # Apply auto colors if enabled
1504
- if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1505
- _set_spine_color('left', charge_col)
1854
+ cur_col = _color_of(sc_charge)
1855
+ if cur_col:
1856
+ _set_spine_color('left', cur_col)
1506
1857
  except Exception:
1507
1858
  pass
1508
1859
  try:
1509
- _rebuild_legend(ax, ax2, file_data)
1510
- fig.canvas.draw_idle()
1860
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
1511
1861
  except Exception:
1512
1862
  pass
1513
1863
  elif sub == 'ry':
1514
1864
  push_state("colors-ry")
1515
- if is_multi_file:
1516
- # Show file list for efficiency triangle selection
1517
- print("\nSelect curve's efficiency to color:")
1518
- for i, f in enumerate(file_data, 1):
1519
- vis_mark = "●" if f.get('visible', True) else "○"
1520
- print(f" {i}. {vis_mark} {f['filename']}")
1521
- choice = input("Enter curve number (1-{}) or 'a' for all, q=cancel: ".format(len(file_data))).strip()
1522
- if not choice or choice.lower() == 'q':
1523
- continue
1524
-
1525
- if choice.lower() == 'a':
1526
- print("\nEfficiency color palettes:")
1527
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1528
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1529
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1530
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1531
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1532
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1533
- if not val or val.lower() == 'q':
1865
+ print("\nCurrent efficiency curves:")
1866
+ for i, f in enumerate(file_data, 1):
1867
+ cur = _color_of(f['sc_eff'])
1868
+ vis_mark = "●" if f.get('visible', True) else "○"
1869
+ print(f" {i}. {vis_mark} {f['filename']} {color_block(cur)} {cur}")
1870
+ uc = get_user_color_list(fig)
1871
+ if uc:
1872
+ print("\nSaved colors (refer as number or u#):")
1873
+ for i, c in enumerate(uc, 1):
1874
+ print(f" {i}: {color_block(c)} {c}")
1875
+ print("\nPalettes:")
1876
+ for idx, name in enumerate(palette_opts, 1):
1877
+ bar = palette_preview(name)
1878
+ print(f" {idx}. {name}")
1879
+ if bar:
1880
+ print(f" {bar}")
1881
+ 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()
1882
+ if not color_input or color_input.lower() == 'q':
1883
+ continue
1884
+ tokens = color_input.split()
1885
+ if len(tokens) == 1:
1886
+ # Single token: apply palette to all files
1887
+ spec = tokens[0]
1888
+ for i, f in enumerate(file_data):
1889
+ col = _resolve_color(spec, i, len(file_data), default_cmap='viridis')
1890
+ if not col:
1534
1891
  continue
1535
- for i, f in enumerate(file_data):
1536
- if val.lower() == 'r':
1537
- # Use Plasma colormap
1538
- import matplotlib.cm as cm
1539
- import matplotlib.colors as mcolors
1540
- plasma = cm.get_cmap('plasma', 10)
1541
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1542
- elif val in ['1', '2', '3', '4', '5']:
1543
- # Use selected palette
1544
- efficiency_palettes = [
1545
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1546
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1547
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1548
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1549
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1550
- ]
1551
- palette = efficiency_palettes[int(val) - 1]
1552
- col = palette[i % len(palette)]
1892
+ try:
1893
+ f['sc_eff'].set_color(col)
1894
+ f['eff_color'] = col
1895
+ # Force update of facecolors for scatter plots
1896
+ if hasattr(f['sc_eff'], 'set_facecolors'):
1897
+ from matplotlib.colors import to_rgba
1898
+ rgba = to_rgba(col)
1899
+ f['sc_eff'].set_facecolors(rgba)
1900
+ except Exception:
1901
+ pass
1902
+ else:
1903
+ # Multiple tokens: parse file:color pairs
1904
+ def _apply_manual_entries_eff(tokens):
1905
+ idx_color_pairs = []
1906
+ i = 0
1907
+ while i < len(tokens):
1908
+ tok = tokens[i]
1909
+ if ':' in tok:
1910
+ idx_str, color = tok.split(':', 1)
1553
1911
  else:
1554
- col = val
1912
+ if i + 1 >= len(tokens):
1913
+ print(f"Skip incomplete entry: {tok}")
1914
+ break
1915
+ idx_str = tok
1916
+ color = tokens[i + 1]
1917
+ i += 1
1918
+ idx_color_pairs.append((idx_str, color))
1919
+ i += 1
1920
+ for idx_str, color in idx_color_pairs:
1555
1921
  try:
1556
- f['sc_eff'].set_color(col)
1557
- f['eff_color'] = col # Store efficiency color
1922
+ file_idx = int(idx_str) - 1
1923
+ except ValueError:
1924
+ print(f"Bad index: {idx_str}")
1925
+ continue
1926
+ if not (0 <= file_idx < len(file_data)):
1927
+ print(f"Index out of range: {idx_str}")
1928
+ continue
1929
+ resolved = resolve_color_token(color, fig)
1930
+ col = resolved if resolved else color
1931
+ if not col:
1932
+ continue
1933
+ try:
1934
+ file_data[file_idx]['sc_eff'].set_color(col)
1935
+ file_data[file_idx]['eff_color'] = col
1936
+ # Force update of facecolors for scatter plots
1937
+ if hasattr(file_data[file_idx]['sc_eff'], 'set_facecolors'):
1938
+ from matplotlib.colors import to_rgba
1939
+ rgba = to_rgba(col)
1940
+ file_data[file_idx]['sc_eff'].set_facecolors(rgba)
1558
1941
  except Exception:
1559
1942
  pass
1560
- else:
1561
- try:
1562
- idx = int(choice) - 1
1563
- if 0 <= idx < len(file_data):
1564
- print("\nEfficiency color palettes:")
1565
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1566
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1567
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1568
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1569
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1570
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1571
- if not val or val.lower() == 'q':
1572
- continue
1573
- if val.lower() == 'r':
1574
- # Use Plasma colormap
1575
- import matplotlib.cm as cm
1576
- import matplotlib.colors as mcolors
1577
- plasma = cm.get_cmap('plasma', 10)
1578
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1579
- elif val in ['1', '2', '3', '4', '5']:
1580
- # Use selected palette
1581
- efficiency_palettes = [
1582
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1583
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1584
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1585
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1586
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1587
- ]
1588
- palette = efficiency_palettes[int(val) - 1]
1589
- col = palette[0] # Use first color from palette for single file
1590
- else:
1591
- col = val
1592
- try:
1593
- file_data[idx]['sc_eff'].set_color(col)
1594
- file_data[idx]['eff_color'] = col # Store efficiency color
1595
- except Exception:
1596
- pass
1597
- else:
1598
- print("Invalid file number.")
1599
- except ValueError:
1600
- print("Invalid input.")
1601
- else:
1602
- # Single file mode
1603
- print("\nEfficiency color palettes:")
1604
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1605
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1606
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1607
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1608
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1609
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1610
- if not val or val.lower() == 'q':
1611
- continue
1612
- if val.lower() == 'r':
1613
- # Use Plasma colormap
1614
- import matplotlib.cm as cm
1615
- import matplotlib.colors as mcolors
1616
- plasma = cm.get_cmap('plasma', 10)
1617
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1618
- elif val in ['1', '2', '3', '4', '5']:
1619
- # Use selected palette
1620
- efficiency_palettes = [
1621
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1622
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1623
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1624
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1625
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1626
- ]
1627
- palette = efficiency_palettes[int(val) - 1]
1628
- col = palette[0] # Use first color from palette
1629
- else:
1630
- col = val
1943
+ _apply_manual_entries_eff(tokens)
1944
+ if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1631
1945
  try:
1632
- sc_eff.set_color(col)
1633
- # Apply auto colors if enabled
1634
- if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1635
- _set_spine_color('right', col)
1946
+ cur_col = _color_of(sc_eff)
1947
+ if cur_col:
1948
+ _set_spine_color('right', cur_col)
1636
1949
  except Exception:
1637
1950
  pass
1638
1951
  try:
1639
- _rebuild_legend(ax, ax2, file_data)
1640
- fig.canvas.draw_idle()
1952
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
1641
1953
  except Exception:
1642
1954
  pass
1643
1955
  else:
@@ -1662,7 +1974,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1662
1974
  auto_status = "ON" if auto_enabled else "OFF"
1663
1975
  print(_colorize_inline_commands(f" a : auto (apply capacity curve color to left y-axis, efficiency to right y-axis) [{auto_status}]"))
1664
1976
  print("q: back to main menu")
1665
- line = input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
1977
+ line = _safe_input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
1666
1978
  if not line or line.lower() == 'q':
1667
1979
  break
1668
1980
  # Handle auto toggle when only one file is loaded
@@ -1734,9 +2046,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1734
2046
 
1735
2047
  last_figure_path = getattr(fig, '_last_figure_export_path', None)
1736
2048
  if last_figure_path:
1737
- fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
2049
+ fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1738
2050
  else:
1739
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
2051
+ fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1740
2052
  if not fname or fname.lower() == 'q':
1741
2053
  _print_menu(); continue
1742
2054
 
@@ -1748,7 +2060,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1748
2060
  if not os.path.exists(last_figure_path):
1749
2061
  print(f"Previous export file not found: {last_figure_path}")
1750
2062
  _print_menu(); continue
1751
- yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
2063
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1752
2064
  if yn != 'y':
1753
2065
  _print_menu(); continue
1754
2066
  target = last_figure_path
@@ -1757,7 +2069,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1757
2069
  idx = int(fname)
1758
2070
  if 1 <= idx <= len(files):
1759
2071
  name = files[idx-1]
1760
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2072
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1761
2073
  if yn != 'y':
1762
2074
  _print_menu(); continue
1763
2075
  target = file_list[idx-1][1] # Full path from list
@@ -1774,10 +2086,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1774
2086
  else:
1775
2087
  target = get_organized_path(fname, 'figure', base_path=base_path)
1776
2088
  if os.path.exists(target):
1777
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2089
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
1778
2090
  if yn != 'y':
1779
2091
  _print_menu(); continue
1780
2092
  if target:
2093
+ # Save current legend position before export (savefig can change layout)
2094
+ saved_legend_pos = None
2095
+ try:
2096
+ saved_legend_pos = getattr(fig, '_cpc_legend_xy_in', None)
2097
+ except Exception:
2098
+ pass
2099
+
1781
2100
  # Remove numbering from legend labels before export
1782
2101
  original_labels = {}
1783
2102
  if is_multi_file:
@@ -1800,18 +2119,88 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1800
2119
  pass
1801
2120
 
1802
2121
  # Export the figure
1803
- fig.savefig(target, bbox_inches='tight')
1804
- print(f"Exported figure to {target}")
1805
- fig._last_figure_export_path = target
1806
-
1807
- # Restore original labels
1808
- if is_multi_file and original_labels:
2122
+ _, _ext = os.path.splitext(target)
2123
+ if _ext.lower() == '.svg':
2124
+ # Temporarily force transparent patches so SVG background stays transparent
1809
2125
  try:
1810
- for artist, label in original_labels.items():
1811
- artist.set_label(label)
1812
- _rebuild_legend(ax, ax2, file_data)
2126
+ _fig_fc = fig.get_facecolor()
2127
+ except Exception:
2128
+ _fig_fc = None
2129
+ try:
2130
+ _ax_fc = ax.get_facecolor()
2131
+ except Exception:
2132
+ _ax_fc = None
2133
+ try:
2134
+ _ax2_fc = ax2.get_facecolor()
2135
+ except Exception:
2136
+ _ax2_fc = None
2137
+ try:
2138
+ if getattr(fig, 'patch', None) is not None:
2139
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
2140
+ if getattr(ax, 'patch', None) is not None:
2141
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
2142
+ if getattr(ax2, 'patch', None) is not None:
2143
+ ax2.patch.set_alpha(0.0); ax2.patch.set_facecolor('none')
1813
2144
  except Exception:
1814
2145
  pass
2146
+ try:
2147
+ fig.savefig(target, bbox_inches='tight', transparent=True, facecolor='none', edgecolor='none')
2148
+ finally:
2149
+ try:
2150
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
2151
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
2152
+ except Exception:
2153
+ pass
2154
+ try:
2155
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
2156
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
2157
+ except Exception:
2158
+ pass
2159
+ try:
2160
+ if _ax2_fc is not None and getattr(ax2, 'patch', None) is not None:
2161
+ ax2.patch.set_alpha(1.0); ax2.patch.set_facecolor(_ax2_fc)
2162
+ except Exception:
2163
+ pass
2164
+ print(f"Exported figure to {target}")
2165
+ fig._last_figure_export_path = target
2166
+
2167
+ # Restore original labels and legend position
2168
+ if is_multi_file and original_labels:
2169
+ try:
2170
+ for artist, label in original_labels.items():
2171
+ artist.set_label(label)
2172
+ _rebuild_legend(ax, ax2, file_data)
2173
+ except Exception:
2174
+ pass
2175
+ # Restore legend position after savefig (which may have changed layout)
2176
+ if saved_legend_pos is not None:
2177
+ try:
2178
+ fig._cpc_legend_xy_in = saved_legend_pos
2179
+ _rebuild_legend(ax, ax2, file_data)
2180
+ fig.canvas.draw_idle()
2181
+ except Exception:
2182
+ pass
2183
+ else:
2184
+ fig.savefig(target, bbox_inches='tight')
2185
+ print(f"Exported figure to {target}")
2186
+ fig._last_figure_export_path = target
2187
+
2188
+ # Restore original labels and legend position
2189
+ if is_multi_file and original_labels:
2190
+ try:
2191
+ for artist, label in original_labels.items():
2192
+ artist.set_label(label)
2193
+ _rebuild_legend(ax, ax2, file_data)
2194
+ except Exception:
2195
+ pass
2196
+ # Restore legend position after savefig (which may have changed layout)
2197
+ if saved_legend_pos is not None:
2198
+ try:
2199
+ fig._cpc_legend_xy_in = saved_legend_pos
2200
+ _rebuild_legend(ax, ax2, file_data)
2201
+ fig.canvas.draw_idle()
2202
+ except Exception:
2203
+ pass
1815
2204
  except Exception as e:
1816
2205
  print(f"Export failed: {e}")
1817
2206
  _print_menu(); continue
@@ -1819,6 +2208,58 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1819
2208
  # Save CPC session (.pkl) with all data and styles
1820
2209
  try:
1821
2210
  from .session import dump_cpc_session
2211
+ # Sync current tick/title visibility (including minors) into stored WASD state before save
2212
+ try:
2213
+ wasd = getattr(fig, '_cpc_wasd_state', {})
2214
+ if not isinstance(wasd, dict):
2215
+ wasd = {}
2216
+ # bottom
2217
+ w = wasd.setdefault('bottom', {})
2218
+ w['ticks'] = bool(tick_state.get('b_ticks', tick_state.get('bx', True)))
2219
+ w['labels'] = bool(tick_state.get('b_labels', tick_state.get('bx', True)))
2220
+ w['minor'] = bool(tick_state.get('mbx', False))
2221
+ w['title'] = bool(ax.xaxis.label.get_visible())
2222
+ try:
2223
+ sp = ax.spines.get('bottom')
2224
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2225
+ except Exception:
2226
+ pass
2227
+ # top
2228
+ w = wasd.setdefault('top', {})
2229
+ w['ticks'] = bool(tick_state.get('t_ticks', tick_state.get('tx', False)))
2230
+ w['labels'] = bool(tick_state.get('t_labels', tick_state.get('tx', False)))
2231
+ w['minor'] = bool(tick_state.get('mtx', False))
2232
+ w['title'] = bool(getattr(ax, '_top_xlabel_on', False))
2233
+ try:
2234
+ sp = ax.spines.get('top')
2235
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', False)
2236
+ except Exception:
2237
+ pass
2238
+ # left
2239
+ w = wasd.setdefault('left', {})
2240
+ w['ticks'] = bool(tick_state.get('l_ticks', tick_state.get('ly', True)))
2241
+ w['labels'] = bool(tick_state.get('l_labels', tick_state.get('ly', True)))
2242
+ w['minor'] = bool(tick_state.get('mly', False))
2243
+ w['title'] = bool(ax.yaxis.label.get_visible())
2244
+ try:
2245
+ sp = ax.spines.get('left')
2246
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2247
+ except Exception:
2248
+ pass
2249
+ # right
2250
+ w = wasd.setdefault('right', {})
2251
+ w['ticks'] = bool(tick_state.get('r_ticks', tick_state.get('ry', True)))
2252
+ w['labels'] = bool(tick_state.get('r_labels', tick_state.get('ry', True)))
2253
+ w['minor'] = bool(tick_state.get('mry', False))
2254
+ w['title'] = bool(ax2.yaxis.label.get_visible() if ax2 is not None else False)
2255
+ try:
2256
+ sp = ax2.spines.get('right') if ax2 is not None else None
2257
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2258
+ except Exception:
2259
+ pass
2260
+ setattr(fig, '_cpc_wasd_state', wasd)
2261
+ except Exception:
2262
+ pass
1822
2263
  folder = choose_save_path(file_paths, purpose="CPC session save")
1823
2264
  if not folder:
1824
2265
  _print_menu(); continue
@@ -1841,7 +2282,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1841
2282
  prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
1842
2283
  else:
1843
2284
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1844
- choice = input(prompt).strip()
2285
+ choice = _safe_input(prompt).strip()
1845
2286
  if not choice or choice.lower() == 'q':
1846
2287
  _print_menu(); continue
1847
2288
  if choice.lower() == 'o':
@@ -1852,7 +2293,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1852
2293
  if not os.path.exists(last_session_path):
1853
2294
  print(f"Previous save file not found: {last_session_path}")
1854
2295
  _print_menu(); continue
1855
- yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2296
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
1856
2297
  if yn != 'y':
1857
2298
  _print_menu(); continue
1858
2299
  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)
@@ -1862,7 +2303,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1862
2303
  idx = int(choice)
1863
2304
  if 1 <= idx <= len(files):
1864
2305
  name = files[idx-1]
1865
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2306
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1866
2307
  if yn != 'y':
1867
2308
  _print_menu(); continue
1868
2309
  target = os.path.join(folder, name)
@@ -1879,7 +2320,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1879
2320
  name = name + '.pkl'
1880
2321
  target = name if os.path.isabs(name) else os.path.join(folder, name)
1881
2322
  if os.path.exists(target):
1882
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2323
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
1883
2324
  if yn != 'y':
1884
2325
  _print_menu(); continue
1885
2326
  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)
@@ -2009,9 +2450,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2009
2450
 
2010
2451
  last_style_path = getattr(fig, '_last_style_export_path', None)
2011
2452
  if last_style_path:
2012
- sub = input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
2453
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
2013
2454
  else:
2014
- sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2455
+ sub = _safe_input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2015
2456
  if sub == 'q':
2016
2457
  break
2017
2458
  if sub == 'r' or sub == '':
@@ -2024,7 +2465,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2024
2465
  if not os.path.exists(last_style_path):
2025
2466
  print(f"Previous export file not found: {last_style_path}")
2026
2467
  continue
2027
- yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2468
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2028
2469
  if yn != 'y':
2029
2470
  continue
2030
2471
  # Rebuild config based on current state
@@ -2050,7 +2491,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2050
2491
  print("Export options:")
2051
2492
  print(" ps = style only (.bps)")
2052
2493
  print(" psg = style + geometry (.bpsg)")
2053
- exp_choice = input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
2494
+ exp_choice = _safe_input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
2054
2495
  if not exp_choice or exp_choice == 'q':
2055
2496
  print("Style export canceled.")
2056
2497
  continue
@@ -2103,9 +2544,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2103
2544
  else:
2104
2545
  print(f" {i}: {fname}")
2105
2546
  if last_style_path:
2106
- choice = input("Enter new filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
2547
+ choice = _safe_input("Enter new filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
2107
2548
  else:
2108
- choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
2549
+ choice = _safe_input("Enter new filename or number to overwrite (q=cancel): ").strip()
2109
2550
  if not choice or choice.lower() == 'q':
2110
2551
  print("Style export canceled.")
2111
2552
  continue
@@ -2117,7 +2558,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2117
2558
  if not os.path.exists(last_style_path):
2118
2559
  print(f"Previous export file not found: {last_style_path}")
2119
2560
  continue
2120
- yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2561
+ yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2121
2562
  if yn != 'y':
2122
2563
  continue
2123
2564
  # Rebuild config based on current state
@@ -2143,7 +2584,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2143
2584
  idx = int(choice)
2144
2585
  if 1 <= idx <= len(files):
2145
2586
  name = files[idx-1]
2146
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2587
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2147
2588
  if yn == 'y':
2148
2589
  target = file_list[idx-1][1] # Full path from list
2149
2590
  else:
@@ -2160,7 +2601,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2160
2601
  else:
2161
2602
  target = get_organized_path(name, 'style', base_path=save_base)
2162
2603
  if os.path.exists(target):
2163
- yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2604
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2164
2605
  if yn != 'y':
2165
2606
  target = None
2166
2607
  if target:
@@ -2222,11 +2663,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2222
2663
  # Toggle efficiency visibility on the right axis
2223
2664
  try:
2224
2665
  push_state("toggle-eff")
2225
- vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2226
- new_vis = not vis
2227
2666
 
2228
- # 1. Hide/show efficiency points
2229
- sc_eff.set_visible(new_vis)
2667
+ # Determine current visibility state (check if any efficiency is visible)
2668
+ if is_multi_file:
2669
+ # In multi-file mode, check if any efficiency is visible
2670
+ 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'))
2671
+ new_vis = not any_eff_visible
2672
+ else:
2673
+ # Single file mode
2674
+ vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2675
+ new_vis = not vis
2676
+
2677
+ # 1. Hide/show efficiency points (all files in multi-file mode)
2678
+ if is_multi_file:
2679
+ for f in file_data:
2680
+ eff_sc = f.get('sc_eff')
2681
+ if eff_sc is not None:
2682
+ try:
2683
+ eff_sc.set_visible(new_vis)
2684
+ except Exception:
2685
+ pass
2686
+ else:
2687
+ sc_eff.set_visible(new_vis)
2230
2688
 
2231
2689
  # 2. Hide/show right y-axis title
2232
2690
  try:
@@ -2242,68 +2700,42 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2242
2700
  except Exception:
2243
2701
  pass
2244
2702
 
2245
- # 4. Rebuild legend to remove/add efficiency entry
2246
- try:
2247
- h1, l1 = ax.get_legend_handles_labels()
2248
- except Exception:
2249
- h1, l1 = [], []
2703
+ # Persist WASD state so save/load and styles honor the toggle
2250
2704
  try:
2251
- h2, l2 = ax2.get_legend_handles_labels()
2252
- except Exception:
2253
- h2, l2 = [], []
2254
-
2255
- # Filter out efficiency entry if hidden
2256
- eff_label = None
2257
- try:
2258
- eff_label = sc_eff.get_label()
2705
+ wasd = getattr(fig, '_cpc_wasd_state', None)
2706
+ if not isinstance(wasd, dict):
2707
+ wasd = {
2708
+ 'top': {'spine': bool(ax.spines.get('top').get_visible()) if ax.spines.get('top') else False,
2709
+ 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
2710
+ 'minor': bool(tick_state.get('mtx', False)),
2711
+ 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
2712
+ 'title': bool(getattr(ax, '_top_xlabel_on', False))},
2713
+ 'bottom': {'spine': bool(ax.spines.get('bottom').get_visible()) if ax.spines.get('bottom') else True,
2714
+ 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
2715
+ 'minor': bool(tick_state.get('mbx', False)),
2716
+ 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
2717
+ 'title': bool(ax.xaxis.label.get_visible()) and bool(ax.get_xlabel())},
2718
+ 'left': {'spine': bool(ax.spines.get('left').get_visible()) if ax.spines.get('left') else True,
2719
+ 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
2720
+ 'minor': bool(tick_state.get('mly', False)),
2721
+ 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
2722
+ 'title': bool(ax.yaxis.label.get_visible()) and bool(ax.get_ylabel())},
2723
+ 'right': {'spine': bool(ax2.spines.get('right').get_visible()) if ax2.spines.get('right') else True,
2724
+ 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', True))),
2725
+ 'minor': bool(tick_state.get('mry', False)),
2726
+ 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', True))),
2727
+ 'title': bool(ax2.yaxis.label.get_visible()) and bool(ax2.get_ylabel())},
2728
+ }
2729
+ wasd.setdefault('right', {})
2730
+ wasd['right']['ticks'] = bool(new_vis)
2731
+ wasd['right']['labels'] = bool(new_vis)
2732
+ wasd['right']['title'] = bool(new_vis)
2733
+ setattr(fig, '_cpc_wasd_state', wasd)
2259
2734
  except Exception:
2260
2735
  pass
2261
2736
 
2262
- pairs1 = list(zip(h1, l1))
2263
- pairs2 = list(zip(h2, l2))
2264
-
2265
- def _keep(pair):
2266
- h, l = pair
2267
- # Drop invisible handles
2268
- try:
2269
- if hasattr(h, 'get_visible') and not h.get_visible():
2270
- return False
2271
- except Exception:
2272
- pass
2273
- # Drop the efficiency label when hidden
2274
- if eff_label and (l == eff_label) and not new_vis:
2275
- return False
2276
- return True
2277
-
2278
- vis_pairs1 = [p for p in pairs1 if _keep(p)]
2279
- vis_pairs2 = [p for p in pairs2 if _keep(p)]
2280
- H = [h for h, _ in vis_pairs1 + vis_pairs2]
2281
- L = [l for _, l in vis_pairs1 + vis_pairs2]
2282
-
2283
- if H:
2284
- try:
2285
- # Honor stored inch-based anchor if present; else fallback to 'best'
2286
- xy_in = getattr(fig, '_cpc_legend_xy_in', None)
2287
- if xy_in is not None:
2288
- try:
2289
- fw, fh = fig.get_size_inches()
2290
- fx = 0.5 + float(xy_in[0]) / float(fw)
2291
- fy = 0.5 + float(xy_in[1]) / float(fh)
2292
- _legend_no_frame(ax, H, L, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
2293
- except Exception:
2294
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2295
- else:
2296
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2297
- except Exception:
2298
- pass
2299
- else:
2300
- # No visible series: hide legend if present
2301
- try:
2302
- leg = ax.get_legend()
2303
- if leg is not None:
2304
- leg.set_visible(False)
2305
- except Exception:
2306
- pass
2737
+ # 4. Rebuild legend to remove/add efficiency entries (preserve position)
2738
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
2307
2739
 
2308
2740
  fig.canvas.draw_idle()
2309
2741
  except Exception:
@@ -2346,7 +2778,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2346
2778
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2347
2779
  print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2348
2780
  while True:
2349
- sub = input("Legend: t=toggle, p=set position, q=back: ").strip().lower()
2781
+ sub = _safe_input("Legend: t=toggle, p=set position, q=back: ").strip().lower()
2350
2782
  if not sub:
2351
2783
  continue
2352
2784
  if sub == 'q':
@@ -2359,15 +2791,14 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2359
2791
  leg.set_visible(False)
2360
2792
  else:
2361
2793
  # Ensure a legend exists at the stored position
2362
- h1, l1 = ax.get_legend_handles_labels()
2363
- h2, l2 = ax2.get_legend_handles_labels()
2364
- if h1 or h2:
2794
+ H, L = _visible_handles_labels(ax, ax2)
2795
+ if H:
2365
2796
  offset = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
2366
2797
  if offset is not None:
2367
2798
  fig._cpc_legend_xy_in = offset
2368
2799
  _apply_legend_position()
2369
2800
  else:
2370
- _legend_no_frame(ax, h1 + h2, l1 + l2, loc='best', borderaxespad=1.0)
2801
+ _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2371
2802
  fig.canvas.draw_idle()
2372
2803
  except Exception:
2373
2804
  pass
@@ -2377,7 +2808,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2377
2808
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2378
2809
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2379
2810
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2380
- pos_cmd = input("Position: (x y) or x=x only, y=y only, q=back: ").strip().lower()
2811
+ pos_cmd = _safe_input("Position: (x y) or x=x only, y=y only, q=back: ").strip().lower()
2381
2812
  if not pos_cmd or pos_cmd == 'q':
2382
2813
  break
2383
2814
  if pos_cmd == 'x':
@@ -2386,7 +2817,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2386
2817
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2387
2818
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2388
2819
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2389
- val = input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
2820
+ val = _safe_input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
2390
2821
  if not val or val.lower() == 'q':
2391
2822
  break
2392
2823
  try:
@@ -2409,7 +2840,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2409
2840
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2410
2841
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2411
2842
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2412
- val = input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
2843
+ val = _safe_input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
2413
2844
  if not val or val.lower() == 'q':
2414
2845
  break
2415
2846
  try:
@@ -2450,11 +2881,11 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2450
2881
  pass
2451
2882
  _print_menu(); continue
2452
2883
  elif key == 'f':
2453
- sub = input("Font: f=family, s=size, q=back: ").strip().lower()
2884
+ sub = _safe_input("Font: f=family, s=size, q=back: ").strip().lower()
2454
2885
  if sub == 'q' or not sub:
2455
2886
  _print_menu(); continue
2456
2887
  if sub == 'f':
2457
- fam = input("Enter font family (e.g., Arial, DejaVu Sans): ").strip()
2888
+ fam = _safe_input("Enter font family (e.g., Arial, DejaVu Sans): ").strip()
2458
2889
  if fam:
2459
2890
  try:
2460
2891
  push_state("font-family")
@@ -2510,7 +2941,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2510
2941
  except Exception:
2511
2942
  pass
2512
2943
  elif sub == 's':
2513
- val = input("Enter font size (number): ").strip()
2944
+ val = _safe_input("Enter font size (number): ").strip()
2514
2945
  try:
2515
2946
  size = float(val)
2516
2947
  push_state("font-size")
@@ -2604,13 +3035,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2604
3035
  print(f" {_colorize_menu('f : change frame (axes spines) and tick widths')}")
2605
3036
  print(f" {_colorize_menu('g : toggle grid lines')}")
2606
3037
  print(f" {_colorize_menu('q : return')}")
2607
- sub = input(_colorize_prompt("Choose (f/g/q): ")).strip().lower()
3038
+ sub = _safe_input(_colorize_prompt("Choose (f/g/q): ")).strip().lower()
2608
3039
  if not sub:
2609
3040
  continue
2610
3041
  if sub == 'q':
2611
3042
  break
2612
3043
  if sub == 'f':
2613
- fw_in = input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
3044
+ fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
2614
3045
  if not fw_in or fw_in.lower() == 'q':
2615
3046
  print("Canceled.")
2616
3047
  continue
@@ -2682,7 +3113,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2682
3113
  except Exception:
2683
3114
  e_ms = 40
2684
3115
  print(f" charge ms={c_ms}, discharge ms={d_ms}, efficiency ms={e_ms}")
2685
- spec = input("Set marker size: 'c <ms>', 'd <ms>', 'e <ms>' (q=cancel): ").strip().lower()
3116
+ spec = _safe_input("Set marker size: 'c <ms>', 'd <ms>', 'e <ms>' (q=cancel): ").strip().lower()
2686
3117
  if not spec or spec == 'q':
2687
3118
  _print_menu(); continue
2688
3119
  parts = spec.split()
@@ -2939,7 +3370,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2939
3370
  print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' to show current state, 'q' to go back."))
2940
3371
  print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
2941
3372
  while True:
2942
- cmd = input(_colorize_prompt("t> ")).strip().lower()
3373
+ cmd = _safe_input(_colorize_prompt("t> ")).strip().lower()
2943
3374
  if not cmd:
2944
3375
  continue
2945
3376
  if cmd == 'q':
@@ -2964,7 +3395,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2964
3395
  # Get current major tick length from axes
2965
3396
  current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
2966
3397
  print(f"Current major tick length: {current_major}")
2967
- new_length_str = input("Enter new major tick length (e.g., 6.0): ").strip()
3398
+ new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
2968
3399
  if not new_length_str:
2969
3400
  continue
2970
3401
  new_major = float(new_length_str)
@@ -3039,7 +3470,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3039
3470
  current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
3040
3471
  current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
3041
3472
  print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
3042
- sub = input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
3473
+ sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
3043
3474
  if not sub:
3044
3475
  continue
3045
3476
  if sub == 'q':
@@ -3076,7 +3507,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3076
3507
  while True:
3077
3508
  current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
3078
3509
  print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
3079
- sub = input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
3510
+ sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
3080
3511
  if not sub:
3081
3512
  continue
3082
3513
  if sub == 'q':
@@ -3106,7 +3537,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3106
3537
  while True:
3107
3538
  current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
3108
3539
  print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
3109
- sub = input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
3540
+ sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
3110
3541
  if not sub:
3111
3542
  continue
3112
3543
  if sub == 'q':
@@ -3137,7 +3568,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3137
3568
  current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
3138
3569
  current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
3139
3570
  print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
3140
- sub = input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
3571
+ sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
3141
3572
  if not sub:
3142
3573
  continue
3143
3574
  if sub == 'q':
@@ -3175,7 +3606,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3175
3606
  print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
3176
3607
  print(" " + _colorize_menu('r : reset all offsets'))
3177
3608
  print(" " + _colorize_menu('q : return'))
3178
- choice = input(_colorize_prompt("p> ")).strip().lower()
3609
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
3179
3610
  if not choice:
3180
3611
  continue
3181
3612
  if choice == 'q':
@@ -3276,7 +3707,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3276
3707
  elif key == 'g':
3277
3708
  while True:
3278
3709
  print("Geometry: p=plot frame, c=canvas, q=back")
3279
- sub = input("Geom> ").strip().lower()
3710
+ sub = _safe_input("Geom> ").strip().lower()
3280
3711
  if not sub:
3281
3712
  continue
3282
3713
  if sub == 'q':
@@ -3299,18 +3730,234 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3299
3730
  # Rename axis titles
3300
3731
  print("Tip: Use LaTeX/mathtext for special characters:")
3301
3732
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
3302
- print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3733
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3303
3734
  while True:
3304
- print("Rename titles: x=x-axis, ly=left y-axis, ry=right y-axis, q=back")
3305
- sub = input("Rename> ").strip().lower()
3735
+ print("Rename: x=x-axis, ly=left y-axis, ry=right y-axis, l=legend labels, q=back")
3736
+ sub = _safe_input("Rename> ").strip().lower()
3306
3737
  if not sub:
3307
3738
  continue
3308
3739
  if sub == 'q':
3309
3740
  break
3310
- if sub == 'x':
3741
+ if sub == 'l':
3742
+ # Rename legend labels (file name in legend)
3743
+ if not is_multi_file:
3744
+ # Single file mode: rename the default file
3745
+ current_file = file_data[0]
3746
+ sc_chg = current_file['sc_charge']
3747
+ sc_dchg = current_file['sc_discharge']
3748
+ sc_eff = current_file['sc_eff']
3749
+
3750
+ # Get current labels
3751
+ chg_label = sc_chg.get_label() or ''
3752
+ dchg_label = sc_dchg.get_label() or ''
3753
+ eff_label = sc_eff.get_label() or ''
3754
+
3755
+ # Extract base filename (everything before " charge", " discharge", or " efficiency")
3756
+ # Also handle patterns like "filename (Chg)", "filename (Dchg)", "filename (Eff)"
3757
+ base_name = current_file.get('filename', 'Data')
3758
+
3759
+ # Try to extract from labels
3760
+ import re
3761
+ for label in [chg_label, dchg_label, eff_label]:
3762
+ if label:
3763
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3764
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3765
+ if bracket_match:
3766
+ potential_base = bracket_match.group(1).strip()
3767
+ if potential_base:
3768
+ base_name = potential_base
3769
+ break
3770
+ else:
3771
+ # Try to extract from text suffix patterns
3772
+ for suffix in [' charge', ' discharge', ' efficiency']:
3773
+ if label.endswith(suffix):
3774
+ potential_base = label[:-len(suffix)].strip()
3775
+ if potential_base:
3776
+ base_name = potential_base
3777
+ break
3778
+ if base_name != current_file.get('filename', 'Data'):
3779
+ break
3780
+
3781
+ print(f"Current file name in legend: '{base_name}'")
3782
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3783
+ if new_name and new_name.lower() != 'q':
3784
+ try:
3785
+ push_state("rename-legend")
3786
+
3787
+ # Extract bracket content from original labels if present
3788
+ import re
3789
+ chg_bracket = ''
3790
+ dchg_bracket = ''
3791
+ eff_bracket = ''
3792
+
3793
+ # Check for bracket patterns in original labels
3794
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3795
+ if chg_match:
3796
+ chg_bracket = chg_match.group(1)
3797
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3798
+ if dchg_match:
3799
+ dchg_bracket = dchg_match.group(1)
3800
+ # Fix capitalization: Dchg -> DChg
3801
+ if dchg_bracket.lower() == 'dchg':
3802
+ dchg_bracket = 'DChg'
3803
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3804
+ if eff_match:
3805
+ eff_bracket = eff_match.group(1)
3806
+
3807
+ # If no brackets found, extract from label suffix or use defaults
3808
+ if not chg_bracket:
3809
+ # Try to extract from " charge" suffix
3810
+ if chg_label.endswith(' charge'):
3811
+ chg_bracket = 'Chg'
3812
+ else:
3813
+ chg_bracket = 'Chg'
3814
+ if not dchg_bracket:
3815
+ # Try to extract from " discharge" suffix
3816
+ if dchg_label.endswith(' discharge'):
3817
+ dchg_bracket = 'DChg'
3818
+ else:
3819
+ dchg_bracket = 'DChg'
3820
+ if not eff_bracket:
3821
+ # Try to extract from " efficiency" suffix
3822
+ if eff_label.endswith(' efficiency'):
3823
+ eff_bracket = 'Eff'
3824
+ else:
3825
+ eff_bracket = 'Eff'
3826
+
3827
+ # Build new labels with brackets preserved
3828
+ new_chg_label = f"{new_name} ({chg_bracket})"
3829
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
3830
+ new_eff_label = f"{new_name} ({eff_bracket})"
3831
+
3832
+ # Update labels
3833
+ sc_chg.set_label(new_chg_label)
3834
+ sc_dchg.set_label(new_dchg_label)
3835
+ sc_eff.set_label(new_eff_label)
3836
+
3837
+ # Update filename in file_data
3838
+ current_file['filename'] = new_name
3839
+
3840
+ # Rebuild legend (preserve position)
3841
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
3842
+ fig.canvas.draw_idle()
3843
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
3844
+ except Exception as e:
3845
+ print(f"Error: {e}")
3846
+ else:
3847
+ # Multi-file mode: show file list and let user select
3848
+ print("\nAvailable files:")
3849
+ _print_file_list(file_data, current_file_idx)
3850
+ file_choice = _safe_input("Enter file number to rename (q=cancel): ").strip()
3851
+ if file_choice and file_choice.lower() != 'q':
3852
+ try:
3853
+ file_idx = int(file_choice) - 1
3854
+ if 0 <= file_idx < len(file_data):
3855
+ current_file = file_data[file_idx]
3856
+ sc_chg = current_file['sc_charge']
3857
+ sc_dchg = current_file['sc_discharge']
3858
+ sc_eff = current_file['sc_eff']
3859
+
3860
+ # Get current labels
3861
+ chg_label = sc_chg.get_label() or ''
3862
+ dchg_label = sc_dchg.get_label() or ''
3863
+ eff_label = sc_eff.get_label() or ''
3864
+
3865
+ # Extract base filename
3866
+ base_name = current_file.get('filename', 'Data')
3867
+ import re
3868
+ for label in [chg_label, dchg_label, eff_label]:
3869
+ if label:
3870
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3871
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3872
+ if bracket_match:
3873
+ potential_base = bracket_match.group(1).strip()
3874
+ if potential_base:
3875
+ base_name = potential_base
3876
+ break
3877
+ else:
3878
+ # Try to extract from text suffix patterns
3879
+ for suffix in [' charge', ' discharge', ' efficiency']:
3880
+ if label.endswith(suffix):
3881
+ potential_base = label[:-len(suffix)].strip()
3882
+ if potential_base:
3883
+ base_name = potential_base
3884
+ break
3885
+ if base_name != current_file.get('filename', 'Data'):
3886
+ break
3887
+
3888
+ print(f"Current file name in legend: '{base_name}'")
3889
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3890
+ if new_name and new_name.lower() != 'q':
3891
+ try:
3892
+ push_state("rename-legend")
3893
+
3894
+ # Extract bracket content from original labels if present
3895
+ import re
3896
+ chg_bracket = ''
3897
+ dchg_bracket = ''
3898
+ eff_bracket = ''
3899
+
3900
+ # Check for bracket patterns in original labels
3901
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3902
+ if chg_match:
3903
+ chg_bracket = chg_match.group(1)
3904
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3905
+ if dchg_match:
3906
+ dchg_bracket = dchg_match.group(1)
3907
+ # Fix capitalization: Dchg -> DChg
3908
+ if dchg_bracket.lower() == 'dchg':
3909
+ dchg_bracket = 'DChg'
3910
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3911
+ if eff_match:
3912
+ eff_bracket = eff_match.group(1)
3913
+
3914
+ # If no brackets found, extract from label suffix or use defaults
3915
+ if not chg_bracket:
3916
+ # Try to extract from " charge" suffix
3917
+ if chg_label.endswith(' charge'):
3918
+ chg_bracket = 'Chg'
3919
+ else:
3920
+ chg_bracket = 'Chg'
3921
+ if not dchg_bracket:
3922
+ # Try to extract from " discharge" suffix
3923
+ if dchg_label.endswith(' discharge'):
3924
+ dchg_bracket = 'DChg'
3925
+ else:
3926
+ dchg_bracket = 'DChg'
3927
+ if not eff_bracket:
3928
+ # Try to extract from " efficiency" suffix
3929
+ if eff_label.endswith(' efficiency'):
3930
+ eff_bracket = 'Eff'
3931
+ else:
3932
+ eff_bracket = 'Eff'
3933
+
3934
+ # Build new labels with brackets preserved
3935
+ new_chg_label = f"{new_name} ({chg_bracket})"
3936
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
3937
+ new_eff_label = f"{new_name} ({eff_bracket})"
3938
+
3939
+ # Update labels
3940
+ sc_chg.set_label(new_chg_label)
3941
+ sc_dchg.set_label(new_dchg_label)
3942
+ sc_eff.set_label(new_eff_label)
3943
+
3944
+ # Update filename in file_data
3945
+ current_file['filename'] = new_name
3946
+
3947
+ # Rebuild legend (preserve position)
3948
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
3949
+ fig.canvas.draw_idle()
3950
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
3951
+ except Exception as e:
3952
+ print(f"Error: {e}")
3953
+ else:
3954
+ print("Invalid file number.")
3955
+ except (ValueError, KeyboardInterrupt):
3956
+ print("Invalid input.")
3957
+ elif sub == 'x':
3311
3958
  current = ax.get_xlabel()
3312
3959
  print(f"Current x-axis title: '{current}'")
3313
- new_title = input("Enter new x-axis title (q=cancel): ")
3960
+ new_title = _safe_input("Enter new x-axis title (q=cancel): ")
3314
3961
  if new_title and new_title.lower() != 'q':
3315
3962
  try:
3316
3963
  push_state("rename-x")
@@ -3329,7 +3976,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3329
3976
  elif sub == 'ly':
3330
3977
  current = ax.get_ylabel()
3331
3978
  print(f"Current left y-axis title: '{current}'")
3332
- new_title = input("Enter new left y-axis title (q=cancel): ")
3979
+ new_title = _safe_input("Enter new left y-axis title (q=cancel): ")
3333
3980
  if new_title and new_title.lower() != 'q':
3334
3981
  try:
3335
3982
  push_state("rename-ly")
@@ -3343,7 +3990,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3343
3990
  elif sub == 'ry':
3344
3991
  current = ax2.get_ylabel()
3345
3992
  print(f"Current right y-axis title: '{current}'")
3346
- new_title = input("Enter new right y-axis title (q=cancel): ")
3993
+ new_title = _safe_input("Enter new right y-axis title (q=cancel): ")
3347
3994
  if new_title and new_title.lower() != 'q':
3348
3995
  try:
3349
3996
  push_state("rename-ry")
@@ -3363,7 +4010,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3363
4010
  while True:
3364
4011
  current_xlim = ax.get_xlim()
3365
4012
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3366
- rng = input("Enter x-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4013
+ rng = _safe_input("Enter x-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3367
4014
  if not rng or rng.lower() == 'q':
3368
4015
  break
3369
4016
  if rng.lower() == 'w':
@@ -3371,7 +4018,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3371
4018
  while True:
3372
4019
  current_xlim = ax.get_xlim()
3373
4020
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3374
- val = input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
4021
+ val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
3375
4022
  if not val or val.lower() == 'q':
3376
4023
  break
3377
4024
  try:
@@ -3398,7 +4045,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3398
4045
  while True:
3399
4046
  current_xlim = ax.get_xlim()
3400
4047
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3401
- val = input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
4048
+ val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
3402
4049
  if not val or val.lower() == 'q':
3403
4050
  break
3404
4051
  try:
@@ -3461,208 +4108,208 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3461
4108
  elif key == 'y':
3462
4109
  while True:
3463
4110
  print("Y-ranges: ly=left axis, ry=right axis, q=back")
3464
- ycmd = input("Y> ").strip().lower()
4111
+ ycmd = _safe_input("Y> ").strip().lower()
3465
4112
  if not ycmd:
3466
4113
  continue
3467
4114
  if ycmd == 'q':
3468
4115
  break
3469
4116
  if ycmd == 'ly':
3470
- current_ylim = ax.get_ylim()
3471
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3472
- rng = input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3473
- if not rng or rng.lower() == 'q':
3474
- continue
3475
- if rng.lower() == 'w':
3476
- # Upper only: change upper limit, fix lower - stay in loop
3477
- while True:
3478
- current_ylim = ax.get_ylim()
3479
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3480
- val = input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3481
- if not val or val.lower() == 'q':
3482
- break
3483
- try:
3484
- new_upper = float(val)
3485
- except (ValueError, KeyboardInterrupt):
3486
- print("Invalid value, ignored.")
3487
- continue
3488
- push_state("y-left-range")
3489
- ax.set_ylim(current_ylim[0], new_upper)
3490
- ax.relim()
3491
- ax.autoscale_view(scalex=False, scaley=True)
3492
- # Reapply legend position after axis change to prevent movement
3493
- try:
3494
- leg = ax.get_legend()
3495
- if leg is not None and leg.get_visible():
3496
- _apply_legend_position()
3497
- except Exception:
3498
- pass
3499
- fig.canvas.draw_idle()
3500
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3501
- if rng.lower() == 'w':
3502
- continue
3503
- if rng.lower() == 's':
3504
- # Lower only: change lower limit, fix upper - stay in loop
3505
- while True:
3506
- current_ylim = ax.get_ylim()
3507
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3508
- val = input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3509
- if not val or val.lower() == 'q':
3510
- break
3511
- try:
3512
- new_lower = float(val)
3513
- except (ValueError, KeyboardInterrupt):
3514
- print("Invalid value, ignored.")
3515
- continue
3516
- push_state("y-left-range")
3517
- ax.set_ylim(new_lower, current_ylim[1])
3518
- ax.relim()
3519
- ax.autoscale_view(scalex=False, scaley=True)
3520
- # Reapply legend position after axis change to prevent movement
3521
- try:
3522
- leg = ax.get_legend()
3523
- if leg is not None and leg.get_visible():
3524
- _apply_legend_position()
3525
- except Exception:
3526
- pass
3527
- fig.canvas.draw_idle()
3528
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3529
- continue
3530
- if rng.lower() == 'a':
3531
- # Auto: restore original range from scatter plots
3532
- push_state("y-left-range-auto")
3533
- try:
3534
- all_y = []
3535
- for sc in [sc_charge, sc_discharge]:
3536
- if sc is not None and hasattr(sc, 'get_offsets'):
3537
- offsets = sc.get_offsets()
3538
- if offsets.size > 0:
3539
- all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
3540
- if all_y:
3541
- orig_min = min(all_y)
3542
- orig_max = max(all_y)
3543
- ax.set_ylim(orig_min, orig_max)
4117
+ while True:
4118
+ current_ylim = ax.get_ylim()
4119
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4120
+ rng = _safe_input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4121
+ if not rng or rng.lower() == 'q':
4122
+ break
4123
+ if rng.lower() == 'w':
4124
+ # Upper only: change upper limit, fix lower - stay in loop
4125
+ while True:
4126
+ current_ylim = ax.get_ylim()
4127
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4128
+ val = _safe_input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4129
+ if not val or val.lower() == 'q':
4130
+ break
4131
+ try:
4132
+ new_upper = float(val)
4133
+ except (ValueError, KeyboardInterrupt):
4134
+ print("Invalid value, ignored.")
4135
+ continue
4136
+ push_state("y-left-range")
4137
+ ax.set_ylim(current_ylim[0], new_upper)
3544
4138
  ax.relim()
3545
4139
  ax.autoscale_view(scalex=False, scaley=True)
4140
+ # Reapply legend position after axis change to prevent movement
4141
+ try:
4142
+ leg = ax.get_legend()
4143
+ if leg is not None and leg.get_visible():
4144
+ _apply_legend_position()
4145
+ except Exception:
4146
+ pass
3546
4147
  fig.canvas.draw_idle()
3547
- print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3548
- else:
3549
- print("No original data available.")
3550
- except Exception as e:
3551
- print(f"Error restoring original left Y range: {e}")
3552
- continue
3553
- parts = rng.replace(',', ' ').split()
3554
- if len(parts) != 2:
3555
- print("Need two numbers."); continue
3556
- try:
3557
- lo = float(parts[0]); hi = float(parts[1])
3558
- if lo == hi:
3559
- print("Min and max cannot be equal."); continue
3560
- push_state("y-left-range")
3561
- ax.set_ylim(min(lo, hi), max(lo, hi))
3562
- fig.canvas.draw_idle()
3563
- except Exception:
3564
- print("Invalid numbers.")
3565
- elif ycmd == 'ry':
3566
- try:
3567
- eff_on = bool(sc_eff.get_visible())
3568
- except Exception:
3569
- eff_on = True
3570
- if not eff_on:
3571
- print("Right Y is not shown; enable efficiency with 'ry' first.")
3572
- continue
3573
- current_ylim = ax2.get_ylim()
3574
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3575
- rng = input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3576
- if not rng or rng.lower() == 'q':
3577
- continue
3578
- if rng.lower() == 'w':
3579
- # Upper only: change upper limit, fix lower - stay in loop
3580
- while True:
3581
- current_ylim = ax2.get_ylim()
3582
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3583
- val = input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3584
- if not val or val.lower() == 'q':
3585
- break
3586
- try:
3587
- new_upper = float(val)
3588
- except (ValueError, KeyboardInterrupt):
3589
- print("Invalid value, ignored.")
3590
- continue
3591
- push_state("y-right-range")
3592
- ax2.set_ylim(current_ylim[0], new_upper)
3593
- ax2.relim()
3594
- ax2.autoscale_view(scalex=False, scaley=True)
3595
- # Reapply legend position after axis change to prevent movement
3596
- try:
3597
- leg = ax.get_legend()
3598
- if leg is not None and leg.get_visible():
3599
- _apply_legend_position()
3600
- except Exception:
3601
- pass
3602
- fig.canvas.draw_idle()
3603
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3604
- if rng.lower() == 'w':
3605
- continue
3606
- if rng.lower() == 's':
3607
- # Lower only: change lower limit, fix upper - stay in loop
3608
- while True:
3609
- current_ylim = ax2.get_ylim()
3610
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3611
- val = input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3612
- if not val or val.lower() == 'q':
3613
- break
3614
- try:
3615
- new_lower = float(val)
3616
- except (ValueError, KeyboardInterrupt):
3617
- print("Invalid value, ignored.")
3618
- continue
3619
- push_state("y-right-range")
3620
- ax2.set_ylim(new_lower, current_ylim[1])
3621
- ax2.relim()
3622
- ax2.autoscale_view(scalex=False, scaley=True)
3623
- # Reapply legend position after axis change to prevent movement
4148
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4149
+ continue
4150
+ if rng.lower() == 's':
4151
+ # Lower only: change lower limit, fix upper - stay in loop
4152
+ while True:
4153
+ current_ylim = ax.get_ylim()
4154
+ print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4155
+ val = _safe_input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4156
+ if not val or val.lower() == 'q':
4157
+ break
4158
+ try:
4159
+ new_lower = float(val)
4160
+ except (ValueError, KeyboardInterrupt):
4161
+ print("Invalid value, ignored.")
4162
+ continue
4163
+ push_state("y-left-range")
4164
+ ax.set_ylim(new_lower, current_ylim[1])
4165
+ ax.relim()
4166
+ ax.autoscale_view(scalex=False, scaley=True)
4167
+ # Reapply legend position after axis change to prevent movement
4168
+ try:
4169
+ leg = ax.get_legend()
4170
+ if leg is not None and leg.get_visible():
4171
+ _apply_legend_position()
4172
+ except Exception:
4173
+ pass
4174
+ fig.canvas.draw_idle()
4175
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4176
+ continue
4177
+ if rng.lower() == 'a':
4178
+ # Auto: restore original range from scatter plots
4179
+ push_state("y-left-range-auto")
3624
4180
  try:
3625
- leg = ax.get_legend()
3626
- if leg is not None and leg.get_visible():
3627
- _apply_legend_position()
3628
- except Exception:
3629
- pass
4181
+ all_y = []
4182
+ for sc in [sc_charge, sc_discharge]:
4183
+ if sc is not None and hasattr(sc, 'get_offsets'):
4184
+ offsets = sc.get_offsets()
4185
+ if offsets.size > 0:
4186
+ all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
4187
+ if all_y:
4188
+ orig_min = min(all_y)
4189
+ orig_max = max(all_y)
4190
+ ax.set_ylim(orig_min, orig_max)
4191
+ ax.relim()
4192
+ ax.autoscale_view(scalex=False, scaley=True)
4193
+ fig.canvas.draw_idle()
4194
+ print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4195
+ else:
4196
+ print("No original data available.")
4197
+ except Exception as e:
4198
+ print(f"Error restoring original left Y range: {e}")
4199
+ continue
4200
+ parts = rng.replace(',', ' ').split()
4201
+ if len(parts) != 2:
4202
+ print("Need two numbers."); continue
4203
+ try:
4204
+ lo = float(parts[0]); hi = float(parts[1])
4205
+ if lo == hi:
4206
+ print("Min and max cannot be equal."); continue
4207
+ push_state("y-left-range")
4208
+ ax.set_ylim(min(lo, hi), max(lo, hi))
3630
4209
  fig.canvas.draw_idle()
3631
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3632
- continue
3633
- if rng.lower() == 'a':
3634
- # Auto: restore original range from efficiency scatter plot
3635
- push_state("y-right-range-auto")
4210
+ except Exception:
4211
+ print("Invalid numbers.")
4212
+ elif ycmd == 'ry':
4213
+ while True:
3636
4214
  try:
3637
- if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
3638
- offsets = sc_eff.get_offsets()
3639
- if offsets.size > 0:
3640
- orig_min = float(offsets[:, 1].min())
3641
- orig_max = float(offsets[:, 1].max())
3642
- ax2.set_ylim(orig_min, orig_max)
3643
- ax2.relim()
3644
- ax2.autoscale_view(scalex=False, scaley=True)
3645
- fig.canvas.draw_idle()
3646
- print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4215
+ eff_on = bool(sc_eff.get_visible())
4216
+ except Exception:
4217
+ eff_on = True
4218
+ if not eff_on:
4219
+ print("Right Y is not shown; enable efficiency with 'ry' first.")
4220
+ break
4221
+ current_ylim = ax2.get_ylim()
4222
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4223
+ rng = _safe_input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4224
+ if not rng or rng.lower() == 'q':
4225
+ break
4226
+ if rng.lower() == 'w':
4227
+ # Upper only: change upper limit, fix lower - stay in loop
4228
+ while True:
4229
+ current_ylim = ax2.get_ylim()
4230
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4231
+ val = _safe_input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4232
+ if not val or val.lower() == 'q':
4233
+ break
4234
+ try:
4235
+ new_upper = float(val)
4236
+ except (ValueError, KeyboardInterrupt):
4237
+ print("Invalid value, ignored.")
4238
+ continue
4239
+ push_state("y-right-range")
4240
+ ax2.set_ylim(current_ylim[0], new_upper)
4241
+ ax2.relim()
4242
+ ax2.autoscale_view(scalex=False, scaley=True)
4243
+ # Reapply legend position after axis change to prevent movement
4244
+ try:
4245
+ leg = ax.get_legend()
4246
+ if leg is not None and leg.get_visible():
4247
+ _apply_legend_position()
4248
+ except Exception:
4249
+ pass
4250
+ fig.canvas.draw_idle()
4251
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4252
+ continue
4253
+ if rng.lower() == 's':
4254
+ # Lower only: change lower limit, fix upper - stay in loop
4255
+ while True:
4256
+ current_ylim = ax2.get_ylim()
4257
+ print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4258
+ val = _safe_input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4259
+ if not val or val.lower() == 'q':
4260
+ break
4261
+ try:
4262
+ new_lower = float(val)
4263
+ except (ValueError, KeyboardInterrupt):
4264
+ print("Invalid value, ignored.")
4265
+ continue
4266
+ push_state("y-right-range")
4267
+ ax2.set_ylim(new_lower, current_ylim[1])
4268
+ ax2.relim()
4269
+ ax2.autoscale_view(scalex=False, scaley=True)
4270
+ # Reapply legend position after axis change to prevent movement
4271
+ try:
4272
+ leg = ax.get_legend()
4273
+ if leg is not None and leg.get_visible():
4274
+ _apply_legend_position()
4275
+ except Exception:
4276
+ pass
4277
+ fig.canvas.draw_idle()
4278
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4279
+ continue
4280
+ if rng.lower() == 'a':
4281
+ # Auto: restore original range from efficiency scatter plot
4282
+ push_state("y-right-range-auto")
4283
+ try:
4284
+ if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
4285
+ offsets = sc_eff.get_offsets()
4286
+ if offsets.size > 0:
4287
+ orig_min = float(offsets[:, 1].min())
4288
+ orig_max = float(offsets[:, 1].max())
4289
+ ax2.set_ylim(orig_min, orig_max)
4290
+ ax2.relim()
4291
+ ax2.autoscale_view(scalex=False, scaley=True)
4292
+ fig.canvas.draw_idle()
4293
+ print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4294
+ else:
4295
+ print("No original data available.")
3647
4296
  else:
3648
4297
  print("No original data available.")
3649
- else:
3650
- print("No original data available.")
3651
- except Exception as e:
3652
- print(f"Error restoring original right Y range: {e}")
3653
- continue
3654
- parts = rng.replace(',', ' ').split()
3655
- if len(parts) != 2:
3656
- print("Need two numbers."); continue
3657
- try:
3658
- lo = float(parts[0]); hi = float(parts[1])
3659
- if lo == hi:
3660
- print("Min and max cannot be equal."); continue
3661
- push_state("y-right-range")
3662
- ax2.set_ylim(min(lo, hi), max(lo, hi))
3663
- fig.canvas.draw_idle()
3664
- except Exception:
3665
- print("Invalid numbers.")
4298
+ except Exception as e:
4299
+ print(f"Error restoring original right Y range: {e}")
4300
+ continue
4301
+ parts = rng.replace(',', ' ').split()
4302
+ if len(parts) != 2:
4303
+ print("Need two numbers."); continue
4304
+ try:
4305
+ lo = float(parts[0]); hi = float(parts[1])
4306
+ if lo == hi:
4307
+ print("Min and max cannot be equal."); continue
4308
+ push_state("y-right-range")
4309
+ ax2.set_ylim(min(lo, hi), max(lo, hi))
4310
+ fig.canvas.draw_idle()
4311
+ except Exception:
4312
+ print("Invalid numbers.")
3666
4313
  _print_menu(); continue
3667
4314
  else:
3668
4315
  print("Unknown key.")