batplot 1.7.25__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:
@@ -548,16 +592,19 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
548
592
  'series': {
549
593
  'charge': {
550
594
  'color': _color_of(sc_charge),
595
+ 'marker': getattr(sc_charge, 'get_marker', lambda: 'o')(),
551
596
  'markersize': float(getattr(sc_charge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_charge, 'get_sizes') else 32.0,
552
597
  'alpha': float(sc_charge.get_alpha()) if sc_charge.get_alpha() is not None else 1.0,
553
598
  },
554
599
  'discharge': {
555
600
  'color': _color_of(sc_discharge),
601
+ 'marker': getattr(sc_discharge, 'get_marker', lambda: 's')(),
556
602
  'markersize': float(getattr(sc_discharge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_discharge, 'get_sizes') else 32.0,
557
603
  'alpha': float(sc_discharge.get_alpha()) if sc_discharge.get_alpha() is not None else 1.0,
558
604
  },
559
605
  'efficiency': {
560
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: '^')(),
561
608
  'markersize': float(getattr(sc_eff, 'get_sizes', lambda: [40])()[0]) if hasattr(sc_eff, 'get_sizes') else 40.0,
562
609
  'alpha': float(sc_eff.get_alpha()) if sc_eff.get_alpha() is not None else 1.0,
563
610
  'visible': bool(getattr(sc_eff, 'get_visible', lambda: True)()),
@@ -569,12 +616,18 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
569
616
  if file_data and isinstance(file_data, list) and len(file_data) > 0:
570
617
  multi_files = []
571
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')
572
622
  file_info = {
573
623
  'filename': f.get('filename', 'unknown'),
574
624
  'visible': f.get('visible', True),
575
- 'charge_color': _color_of(f.get('sc_charge')),
576
- 'discharge_color': _color_of(f.get('sc_discharge')),
577
- '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 '^',
578
631
  }
579
632
  # Save legend labels
580
633
  try:
@@ -726,6 +779,13 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
726
779
  # Apply marker sizes and alpha globally to all files in multi-file mode
727
780
  if is_multi_file:
728
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'])
729
789
  # Marker sizes (global)
730
790
  if ch.get('markersize') is not None and hasattr(f['sc_charge'], 'set_sizes'):
731
791
  f['sc_charge'].set_sizes([float(ch['markersize'])])
@@ -759,6 +819,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
759
819
  if ch:
760
820
  if ch.get('color') is not None:
761
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'])
762
824
  if ch.get('markersize') is not None and hasattr(sc_charge, 'set_sizes'):
763
825
  sc_charge.set_sizes([float(ch['markersize'])])
764
826
  if ch.get('alpha') is not None:
@@ -766,6 +828,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
766
828
  if dh:
767
829
  if dh.get('color') is not None:
768
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'])
769
833
  if dh.get('markersize') is not None and hasattr(sc_discharge, 'set_sizes'):
770
834
  sc_discharge.set_sizes([float(dh['markersize'])])
771
835
  if dh.get('alpha') is not None:
@@ -776,6 +840,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
776
840
  sc_eff.set_color(ef['color'])
777
841
  except Exception:
778
842
  pass
843
+ if ef.get('marker') is not None and hasattr(sc_eff, 'set_marker'):
844
+ sc_eff.set_marker(ef['marker'])
779
845
  if ef.get('markersize') is not None and hasattr(sc_eff, 'set_sizes'):
780
846
  sc_eff.set_sizes([float(ef['markersize'])])
781
847
  if ef.get('alpha') is not None:
@@ -803,6 +869,27 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
803
869
  leg.set_visible(leg_visible)
804
870
  if leg_visible:
805
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
806
893
  except Exception:
807
894
  pass
808
895
  # Apply tick visibility/widths and spines
@@ -1050,6 +1137,42 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
1050
1137
  for i, f_info in enumerate(multi_files):
1051
1138
  if i < len(file_data):
1052
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
1053
1176
  # Restore legend labels
1054
1177
  if 'charge_label' in f_info and f.get('sc_charge'):
1055
1178
  try:
@@ -1206,8 +1329,22 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1206
1329
  if file_data is None:
1207
1330
  # Backward compatibility: create file_data structure from single file
1208
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
1209
1346
  file_data = [{
1210
- 'filename': 'Data', # Default filename
1347
+ 'filename': filename,
1211
1348
  'sc_charge': sc_charge, # Charge capacity scatter artist
1212
1349
  'sc_discharge': sc_discharge, # Discharge capacity scatter artist
1213
1350
  'sc_eff': sc_eff, # Efficiency scatter artist
@@ -1327,6 +1464,27 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1327
1464
  if k in tick_state:
1328
1465
  tick_state[k] = bool(v)
1329
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
1330
1488
  try:
1331
1489
  fig.canvas.draw()
1332
1490
  except Exception:
@@ -1443,7 +1601,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1443
1601
  # Update current file's scatter artists for commands that need them
1444
1602
  sc_charge, sc_discharge, sc_eff = _get_current_file_artists(file_data, current_file_idx)
1445
1603
 
1446
- key = input("Press a key: ").strip().lower()
1604
+ key = _safe_input("Press a key: ").strip().lower()
1447
1605
  except (KeyboardInterrupt, EOFError):
1448
1606
  print("\n\nExiting interactive menu...")
1449
1607
  break
@@ -1455,7 +1613,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1455
1613
  try:
1456
1614
  if is_multi_file:
1457
1615
  _print_file_list(file_data, current_file_idx)
1458
- 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()
1459
1617
  if choice.lower() == 'q':
1460
1618
  _print_menu()
1461
1619
  _print_file_list(file_data, current_file_idx)
@@ -1505,7 +1663,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1505
1663
 
1506
1664
  if key == 'q':
1507
1665
  try:
1508
- 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()
1509
1667
  except Exception:
1510
1668
  confirm = 'y'
1511
1669
  if confirm == 'y':
@@ -1516,283 +1674,282 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1516
1674
  restore_state()
1517
1675
  _print_menu(); continue
1518
1676
  elif key == 'c':
1519
- # 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
1520
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
+
1521
1749
  while True:
1522
- print("\nColors: ly=capacity curves, ry=efficiency triangles, q=back")
1523
- 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()
1524
1752
  if not sub:
1525
1753
  continue
1526
1754
  if sub == 'q':
1527
1755
  break
1756
+ if sub == 'u':
1757
+ manage_user_colors(fig); continue
1528
1758
  if sub == 'ly':
1529
- if is_multi_file:
1530
- # Show file list for selection
1531
- print("\nSelect curve to color:")
1532
- for i, f in enumerate(file_data, 1):
1533
- vis_mark = "●" if f.get('visible', True) else "○"
1534
- print(f" {i}. {vis_mark} {f['filename']}")
1535
- choice = input("Enter curve number (1-{}) or 'a' for all, q=cancel: ".format(len(file_data))).strip()
1536
- if not choice or choice.lower() == 'q':
1537
- continue
1538
-
1539
- push_state("colors-ly")
1540
- if choice.lower() == 'a':
1541
- # Apply to all files
1542
- print("\nCharge color palettes (discharge will be auto-generated):")
1543
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1544
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1545
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1546
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1547
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1548
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1549
- 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:
1550
1786
  continue
1551
- for i, f in enumerate(file_data):
1552
- if spec.lower() == 'r':
1553
- # Use Viridis colormap
1554
- import matplotlib.cm as cm
1555
- import matplotlib.colors as mcolors
1556
- viridis = cm.get_cmap('viridis', 10)
1557
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1558
-
1559
- elif spec in ['1', '2', '3', '4', '5']:
1560
- # Use selected palette
1561
- charge_palettes = [
1562
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1563
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1564
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1565
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1566
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1567
- ]
1568
- palette = charge_palettes[int(spec) - 1]
1569
- 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)
1570
1813
  else:
1571
- 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
1572
1835
  discharge_col = _generate_similar_color(charge_col)
1573
1836
  try:
1574
- f['sc_charge'].set_color(charge_col)
1575
- f['sc_discharge'].set_color(discharge_col)
1576
- 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)
1577
1849
  except Exception:
1578
1850
  pass
1579
- else:
1580
- # Apply to selected file
1581
- try:
1582
- idx = int(choice) - 1
1583
- if 0 <= idx < len(file_data):
1584
- print("\nCharge color palettes (discharge will be auto-generated):")
1585
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1586
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1587
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1588
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1589
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1590
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1591
- if not spec or spec.lower() == 'q':
1592
- continue
1593
- if spec.lower() == 'r':
1594
- # Use Viridis colormap
1595
- import matplotlib.cm as cm
1596
- import matplotlib.colors as mcolors
1597
- viridis = cm.get_cmap('viridis', 10)
1598
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1599
- elif spec in ['1', '2', '3', '4', '5']:
1600
- # Use selected palette
1601
- charge_palettes = [
1602
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1603
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1604
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1605
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1606
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1607
- ]
1608
- palette = charge_palettes[int(spec) - 1]
1609
- charge_col = palette[0] # Use first color from palette for single file
1610
- else:
1611
- charge_col = spec
1612
- discharge_col = _generate_similar_color(charge_col)
1613
- try:
1614
- file_data[idx]['sc_charge'].set_color(charge_col)
1615
- file_data[idx]['sc_discharge'].set_color(discharge_col)
1616
- file_data[idx]['color'] = charge_col
1617
- except Exception:
1618
- pass
1619
- else:
1620
- print("Invalid file number.")
1621
- except ValueError:
1622
- print("Invalid input.")
1623
- else:
1624
- # Single file mode
1625
- push_state("colors-ly")
1626
- print("\nCharge color palettes (discharge will be auto-generated):")
1627
- print(" 1. Reds: #d62728, #c62828, #b71c1c, #8b0000, #a30000")
1628
- print(" 2. Oranges: #ff7f0e, #ff6f00, #ff5722, #f4511e, #e64a19")
1629
- print(" 3. Pinks/Magentas: #e377c2, #d81b60, #c2185b, #ad1457, #880e4f")
1630
- print(" 4. Purples: #9c27b0, #8e24aa, #7b1fa2, #6a1b9a, #4a148c")
1631
- print(" 5. Deep oranges/reds: #d84315, #bf360c, #c2185b, #d32f2f, #c62828")
1632
- spec = input("Enter color (name/hex), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1633
- if not spec or spec.lower() == 'q':
1634
- continue
1635
- if spec.strip().lower() == 'r':
1636
- # Use Viridis colormap
1637
- import matplotlib.cm as cm
1638
- import matplotlib.colors as mcolors
1639
- viridis = cm.get_cmap('viridis', 10)
1640
- charge_col = mcolors.rgb2hex(viridis(_random.random())[:3])
1641
- elif spec in ['1', '2', '3', '4', '5']:
1642
- # Use selected palette
1643
- charge_palettes = [
1644
- ['#d62728', '#c62828', '#b71c1c', '#8b0000', '#a30000'],
1645
- ['#ff7f0e', '#ff6f00', '#ff5722', '#f4511e', '#e64a19'],
1646
- ['#e377c2', '#d81b60', '#c2185b', '#ad1457', '#880e4f'],
1647
- ['#9c27b0', '#8e24aa', '#7b1fa2', '#6a1b9a', '#4a148c'],
1648
- ['#d84315', '#bf360c', '#c2185b', '#d32f2f', '#c62828']
1649
- ]
1650
- palette = charge_palettes[int(spec) - 1]
1651
- charge_col = palette[0] # Use first color from palette
1652
- else:
1653
- charge_col = spec
1654
- 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):
1655
1853
  try:
1656
- sc_charge.set_color(charge_col)
1657
- sc_discharge.set_color(discharge_col)
1658
- # Apply auto colors if enabled
1659
- if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1660
- _set_spine_color('left', charge_col)
1854
+ cur_col = _color_of(sc_charge)
1855
+ if cur_col:
1856
+ _set_spine_color('left', cur_col)
1661
1857
  except Exception:
1662
1858
  pass
1663
1859
  try:
1664
- _rebuild_legend(ax, ax2, file_data)
1665
- fig.canvas.draw_idle()
1860
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
1666
1861
  except Exception:
1667
1862
  pass
1668
1863
  elif sub == 'ry':
1669
1864
  push_state("colors-ry")
1670
- if is_multi_file:
1671
- # Show file list for efficiency triangle selection
1672
- print("\nSelect curve's efficiency to color:")
1673
- for i, f in enumerate(file_data, 1):
1674
- vis_mark = "●" if f.get('visible', True) else "○"
1675
- print(f" {i}. {vis_mark} {f['filename']}")
1676
- choice = input("Enter curve number (1-{}) or 'a' for all, q=cancel: ".format(len(file_data))).strip()
1677
- if not choice or choice.lower() == 'q':
1678
- continue
1679
-
1680
- if choice.lower() == 'a':
1681
- print("\nEfficiency color palettes:")
1682
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1683
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1684
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1685
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1686
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1687
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1688
- 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:
1689
1891
  continue
1690
- for i, f in enumerate(file_data):
1691
- if val.lower() == 'r':
1692
- # Use Plasma colormap
1693
- import matplotlib.cm as cm
1694
- import matplotlib.colors as mcolors
1695
- plasma = cm.get_cmap('plasma', 10)
1696
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1697
- elif val in ['1', '2', '3', '4', '5']:
1698
- # Use selected palette
1699
- efficiency_palettes = [
1700
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1701
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1702
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1703
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1704
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1705
- ]
1706
- palette = efficiency_palettes[int(val) - 1]
1707
- 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)
1708
1911
  else:
1709
- 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:
1921
+ try:
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
1710
1933
  try:
1711
- f['sc_eff'].set_color(col)
1712
- f['eff_color'] = col # Store efficiency color
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)
1713
1941
  except Exception:
1714
1942
  pass
1715
- else:
1716
- try:
1717
- idx = int(choice) - 1
1718
- if 0 <= idx < len(file_data):
1719
- print("\nEfficiency color palettes:")
1720
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1721
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1722
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1723
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1724
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1725
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1726
- if not val or val.lower() == 'q':
1727
- continue
1728
- if val.lower() == 'r':
1729
- # Use Plasma colormap
1730
- import matplotlib.cm as cm
1731
- import matplotlib.colors as mcolors
1732
- plasma = cm.get_cmap('plasma', 10)
1733
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1734
- elif val in ['1', '2', '3', '4', '5']:
1735
- # Use selected palette
1736
- efficiency_palettes = [
1737
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1738
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1739
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1740
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1741
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1742
- ]
1743
- palette = efficiency_palettes[int(val) - 1]
1744
- col = palette[0] # Use first color from palette for single file
1745
- else:
1746
- col = val
1747
- try:
1748
- file_data[idx]['sc_eff'].set_color(col)
1749
- file_data[idx]['eff_color'] = col # Store efficiency color
1750
- except Exception:
1751
- pass
1752
- else:
1753
- print("Invalid file number.")
1754
- except ValueError:
1755
- print("Invalid input.")
1756
- else:
1757
- # Single file mode
1758
- print("\nEfficiency color palettes:")
1759
- print(" 1. Blues: #1f77b4, #1976d2, #1565c0, #0d47a1, #01579b")
1760
- print(" 2. Cyans/Teals: #17becf, #00acc1, #0097a7, #00838f, #006064")
1761
- print(" 3. Purples/Indigos: #9467bd, #5e35b1, #512da8, #4527a0, #311b92")
1762
- print(" 4. Deep blues: #2196f3, #1e88e5, #1976d2, #1565c0, #0d47a1")
1763
- print(" 5. Dark cyans/purples: #0097a7, #00838f, #006064, #5e35b1, #4527a0")
1764
- val = input("Enter color (hex/name), palette number (1-5), or 'r' for random (q=cancel): ").strip()
1765
- if not val or val.lower() == 'q':
1766
- continue
1767
- if val.lower() == 'r':
1768
- # Use Plasma colormap
1769
- import matplotlib.cm as cm
1770
- import matplotlib.colors as mcolors
1771
- plasma = cm.get_cmap('plasma', 10)
1772
- col = mcolors.rgb2hex(plasma(_random.random())[:3])
1773
- elif val in ['1', '2', '3', '4', '5']:
1774
- # Use selected palette
1775
- efficiency_palettes = [
1776
- ['#1f77b4', '#1976d2', '#1565c0', '#0d47a1', '#01579b'],
1777
- ['#17becf', '#00acc1', '#0097a7', '#00838f', '#006064'],
1778
- ['#9467bd', '#5e35b1', '#512da8', '#4527a0', '#311b92'],
1779
- ['#2196f3', '#1e88e5', '#1976d2', '#1565c0', '#0d47a1'],
1780
- ['#0097a7', '#00838f', '#006064', '#5e35b1', '#4527a0']
1781
- ]
1782
- palette = efficiency_palettes[int(val) - 1]
1783
- col = palette[0] # Use first color from palette
1784
- else:
1785
- col = val
1943
+ _apply_manual_entries_eff(tokens)
1944
+ if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1786
1945
  try:
1787
- sc_eff.set_color(col)
1788
- # Apply auto colors if enabled
1789
- if not is_multi_file and getattr(fig, '_cpc_spine_auto', False):
1790
- _set_spine_color('right', col)
1946
+ cur_col = _color_of(sc_eff)
1947
+ if cur_col:
1948
+ _set_spine_color('right', cur_col)
1791
1949
  except Exception:
1792
1950
  pass
1793
1951
  try:
1794
- _rebuild_legend(ax, ax2, file_data)
1795
- fig.canvas.draw_idle()
1952
+ _rebuild_legend(ax, ax2, file_data); fig.canvas.draw_idle()
1796
1953
  except Exception:
1797
1954
  pass
1798
1955
  else:
@@ -1817,7 +1974,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1817
1974
  auto_status = "ON" if auto_enabled else "OFF"
1818
1975
  print(_colorize_inline_commands(f" a : auto (apply capacity curve color to left y-axis, efficiency to right y-axis) [{auto_status}]"))
1819
1976
  print("q: back to main menu")
1820
- 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()
1821
1978
  if not line or line.lower() == 'q':
1822
1979
  break
1823
1980
  # Handle auto toggle when only one file is loaded
@@ -1889,9 +2046,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1889
2046
 
1890
2047
  last_figure_path = getattr(fig, '_last_figure_export_path', None)
1891
2048
  if last_figure_path:
1892
- 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()
1893
2050
  else:
1894
- 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()
1895
2052
  if not fname or fname.lower() == 'q':
1896
2053
  _print_menu(); continue
1897
2054
 
@@ -1903,7 +2060,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1903
2060
  if not os.path.exists(last_figure_path):
1904
2061
  print(f"Previous export file not found: {last_figure_path}")
1905
2062
  _print_menu(); continue
1906
- 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()
1907
2064
  if yn != 'y':
1908
2065
  _print_menu(); continue
1909
2066
  target = last_figure_path
@@ -1912,7 +2069,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1912
2069
  idx = int(fname)
1913
2070
  if 1 <= idx <= len(files):
1914
2071
  name = files[idx-1]
1915
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2072
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
1916
2073
  if yn != 'y':
1917
2074
  _print_menu(); continue
1918
2075
  target = file_list[idx-1][1] # Full path from list
@@ -1929,10 +2086,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1929
2086
  else:
1930
2087
  target = get_organized_path(fname, 'figure', base_path=base_path)
1931
2088
  if os.path.exists(target):
1932
- 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()
1933
2090
  if yn != 'y':
1934
2091
  _print_menu(); continue
1935
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
+
1936
2100
  # Remove numbering from legend labels before export
1937
2101
  original_labels = {}
1938
2102
  if is_multi_file:
@@ -1997,19 +2161,46 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1997
2161
  ax2.patch.set_alpha(1.0); ax2.patch.set_facecolor(_ax2_fc)
1998
2162
  except Exception:
1999
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
2000
2183
  else:
2001
2184
  fig.savefig(target, bbox_inches='tight')
2002
- print(f"Exported figure to {target}")
2003
- fig._last_figure_export_path = target
2004
-
2005
- # Restore original labels
2006
- if is_multi_file and original_labels:
2007
- try:
2008
- for artist, label in original_labels.items():
2009
- artist.set_label(label)
2010
- _rebuild_legend(ax, ax2, file_data)
2011
- except Exception:
2012
- pass
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
2013
2204
  except Exception as e:
2014
2205
  print(f"Export failed: {e}")
2015
2206
  _print_menu(); continue
@@ -2091,7 +2282,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2091
2282
  prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2092
2283
  else:
2093
2284
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2094
- choice = input(prompt).strip()
2285
+ choice = _safe_input(prompt).strip()
2095
2286
  if not choice or choice.lower() == 'q':
2096
2287
  _print_menu(); continue
2097
2288
  if choice.lower() == 'o':
@@ -2102,7 +2293,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2102
2293
  if not os.path.exists(last_session_path):
2103
2294
  print(f"Previous save file not found: {last_session_path}")
2104
2295
  _print_menu(); continue
2105
- 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()
2106
2297
  if yn != 'y':
2107
2298
  _print_menu(); continue
2108
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)
@@ -2112,7 +2303,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2112
2303
  idx = int(choice)
2113
2304
  if 1 <= idx <= len(files):
2114
2305
  name = files[idx-1]
2115
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2306
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2116
2307
  if yn != 'y':
2117
2308
  _print_menu(); continue
2118
2309
  target = os.path.join(folder, name)
@@ -2129,7 +2320,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2129
2320
  name = name + '.pkl'
2130
2321
  target = name if os.path.isabs(name) else os.path.join(folder, name)
2131
2322
  if os.path.exists(target):
2132
- 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()
2133
2324
  if yn != 'y':
2134
2325
  _print_menu(); continue
2135
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)
@@ -2259,9 +2450,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2259
2450
 
2260
2451
  last_style_path = getattr(fig, '_last_style_export_path', None)
2261
2452
  if last_style_path:
2262
- 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()
2263
2454
  else:
2264
- 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()
2265
2456
  if sub == 'q':
2266
2457
  break
2267
2458
  if sub == 'r' or sub == '':
@@ -2274,7 +2465,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2274
2465
  if not os.path.exists(last_style_path):
2275
2466
  print(f"Previous export file not found: {last_style_path}")
2276
2467
  continue
2277
- 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()
2278
2469
  if yn != 'y':
2279
2470
  continue
2280
2471
  # Rebuild config based on current state
@@ -2300,7 +2491,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2300
2491
  print("Export options:")
2301
2492
  print(" ps = style only (.bps)")
2302
2493
  print(" psg = style + geometry (.bpsg)")
2303
- 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()
2304
2495
  if not exp_choice or exp_choice == 'q':
2305
2496
  print("Style export canceled.")
2306
2497
  continue
@@ -2353,9 +2544,9 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2353
2544
  else:
2354
2545
  print(f" {i}: {fname}")
2355
2546
  if last_style_path:
2356
- 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()
2357
2548
  else:
2358
- 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()
2359
2550
  if not choice or choice.lower() == 'q':
2360
2551
  print("Style export canceled.")
2361
2552
  continue
@@ -2367,7 +2558,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2367
2558
  if not os.path.exists(last_style_path):
2368
2559
  print(f"Previous export file not found: {last_style_path}")
2369
2560
  continue
2370
- 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()
2371
2562
  if yn != 'y':
2372
2563
  continue
2373
2564
  # Rebuild config based on current state
@@ -2393,7 +2584,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2393
2584
  idx = int(choice)
2394
2585
  if 1 <= idx <= len(files):
2395
2586
  name = files[idx-1]
2396
- yn = input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2587
+ yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
2397
2588
  if yn == 'y':
2398
2589
  target = file_list[idx-1][1] # Full path from list
2399
2590
  else:
@@ -2410,7 +2601,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2410
2601
  else:
2411
2602
  target = get_organized_path(name, 'style', base_path=save_base)
2412
2603
  if os.path.exists(target):
2413
- 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()
2414
2605
  if yn != 'y':
2415
2606
  target = None
2416
2607
  if target:
@@ -2587,7 +2778,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2587
2778
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2588
2779
  print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2589
2780
  while True:
2590
- 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()
2591
2782
  if not sub:
2592
2783
  continue
2593
2784
  if sub == 'q':
@@ -2617,7 +2808,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2617
2808
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2618
2809
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2619
2810
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2620
- 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()
2621
2812
  if not pos_cmd or pos_cmd == 'q':
2622
2813
  break
2623
2814
  if pos_cmd == 'x':
@@ -2626,7 +2817,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2626
2817
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2627
2818
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2628
2819
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2629
- 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()
2630
2821
  if not val or val.lower() == 'q':
2631
2822
  break
2632
2823
  try:
@@ -2649,7 +2840,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2649
2840
  xy_in = getattr(fig, '_cpc_legend_xy_in', (0.0, 0.0))
2650
2841
  xy_in = _sanitize_legend_offset(xy_in) or (0.0, 0.0)
2651
2842
  print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
2652
- 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()
2653
2844
  if not val or val.lower() == 'q':
2654
2845
  break
2655
2846
  try:
@@ -2690,11 +2881,11 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2690
2881
  pass
2691
2882
  _print_menu(); continue
2692
2883
  elif key == 'f':
2693
- sub = input("Font: f=family, s=size, q=back: ").strip().lower()
2884
+ sub = _safe_input("Font: f=family, s=size, q=back: ").strip().lower()
2694
2885
  if sub == 'q' or not sub:
2695
2886
  _print_menu(); continue
2696
2887
  if sub == 'f':
2697
- fam = input("Enter font family (e.g., Arial, DejaVu Sans): ").strip()
2888
+ fam = _safe_input("Enter font family (e.g., Arial, DejaVu Sans): ").strip()
2698
2889
  if fam:
2699
2890
  try:
2700
2891
  push_state("font-family")
@@ -2750,7 +2941,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2750
2941
  except Exception:
2751
2942
  pass
2752
2943
  elif sub == 's':
2753
- val = input("Enter font size (number): ").strip()
2944
+ val = _safe_input("Enter font size (number): ").strip()
2754
2945
  try:
2755
2946
  size = float(val)
2756
2947
  push_state("font-size")
@@ -2844,13 +3035,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2844
3035
  print(f" {_colorize_menu('f : change frame (axes spines) and tick widths')}")
2845
3036
  print(f" {_colorize_menu('g : toggle grid lines')}")
2846
3037
  print(f" {_colorize_menu('q : return')}")
2847
- sub = input(_colorize_prompt("Choose (f/g/q): ")).strip().lower()
3038
+ sub = _safe_input(_colorize_prompt("Choose (f/g/q): ")).strip().lower()
2848
3039
  if not sub:
2849
3040
  continue
2850
3041
  if sub == 'q':
2851
3042
  break
2852
3043
  if sub == 'f':
2853
- 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()
2854
3045
  if not fw_in or fw_in.lower() == 'q':
2855
3046
  print("Canceled.")
2856
3047
  continue
@@ -2922,7 +3113,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2922
3113
  except Exception:
2923
3114
  e_ms = 40
2924
3115
  print(f" charge ms={c_ms}, discharge ms={d_ms}, efficiency ms={e_ms}")
2925
- 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()
2926
3117
  if not spec or spec == 'q':
2927
3118
  _print_menu(); continue
2928
3119
  parts = spec.split()
@@ -3179,7 +3370,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3179
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."))
3180
3371
  print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
3181
3372
  while True:
3182
- cmd = input(_colorize_prompt("t> ")).strip().lower()
3373
+ cmd = _safe_input(_colorize_prompt("t> ")).strip().lower()
3183
3374
  if not cmd:
3184
3375
  continue
3185
3376
  if cmd == 'q':
@@ -3204,7 +3395,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3204
3395
  # Get current major tick length from axes
3205
3396
  current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
3206
3397
  print(f"Current major tick length: {current_major}")
3207
- 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()
3208
3399
  if not new_length_str:
3209
3400
  continue
3210
3401
  new_major = float(new_length_str)
@@ -3279,7 +3470,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3279
3470
  current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
3280
3471
  current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
3281
3472
  print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
3282
- 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()
3283
3474
  if not sub:
3284
3475
  continue
3285
3476
  if sub == 'q':
@@ -3316,7 +3507,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3316
3507
  while True:
3317
3508
  current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
3318
3509
  print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
3319
- 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()
3320
3511
  if not sub:
3321
3512
  continue
3322
3513
  if sub == 'q':
@@ -3346,7 +3537,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3346
3537
  while True:
3347
3538
  current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
3348
3539
  print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
3349
- 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()
3350
3541
  if not sub:
3351
3542
  continue
3352
3543
  if sub == 'q':
@@ -3377,7 +3568,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3377
3568
  current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
3378
3569
  current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
3379
3570
  print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
3380
- 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()
3381
3572
  if not sub:
3382
3573
  continue
3383
3574
  if sub == 'q':
@@ -3415,7 +3606,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3415
3606
  print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
3416
3607
  print(" " + _colorize_menu('r : reset all offsets'))
3417
3608
  print(" " + _colorize_menu('q : return'))
3418
- choice = input(_colorize_prompt("p> ")).strip().lower()
3609
+ choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
3419
3610
  if not choice:
3420
3611
  continue
3421
3612
  if choice == 'q':
@@ -3516,7 +3707,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3516
3707
  elif key == 'g':
3517
3708
  while True:
3518
3709
  print("Geometry: p=plot frame, c=canvas, q=back")
3519
- sub = input("Geom> ").strip().lower()
3710
+ sub = _safe_input("Geom> ").strip().lower()
3520
3711
  if not sub:
3521
3712
  continue
3522
3713
  if sub == 'q':
@@ -3542,7 +3733,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3542
3733
  print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3543
3734
  while True:
3544
3735
  print("Rename: x=x-axis, ly=left y-axis, ry=right y-axis, l=legend labels, q=back")
3545
- sub = input("Rename> ").strip().lower()
3736
+ sub = _safe_input("Rename> ").strip().lower()
3546
3737
  if not sub:
3547
3738
  continue
3548
3739
  if sub == 'q':
@@ -3588,7 +3779,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3588
3779
  break
3589
3780
 
3590
3781
  print(f"Current file name in legend: '{base_name}'")
3591
- new_name = input("Enter new file name (q=cancel): ").strip()
3782
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3592
3783
  if new_name and new_name.lower() != 'q':
3593
3784
  try:
3594
3785
  push_state("rename-legend")
@@ -3656,7 +3847,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3656
3847
  # Multi-file mode: show file list and let user select
3657
3848
  print("\nAvailable files:")
3658
3849
  _print_file_list(file_data, current_file_idx)
3659
- file_choice = input("Enter file number to rename (q=cancel): ").strip()
3850
+ file_choice = _safe_input("Enter file number to rename (q=cancel): ").strip()
3660
3851
  if file_choice and file_choice.lower() != 'q':
3661
3852
  try:
3662
3853
  file_idx = int(file_choice) - 1
@@ -3695,7 +3886,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3695
3886
  break
3696
3887
 
3697
3888
  print(f"Current file name in legend: '{base_name}'")
3698
- new_name = input("Enter new file name (q=cancel): ").strip()
3889
+ new_name = _safe_input("Enter new file name (q=cancel): ").strip()
3699
3890
  if new_name and new_name.lower() != 'q':
3700
3891
  try:
3701
3892
  push_state("rename-legend")
@@ -3766,7 +3957,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3766
3957
  elif sub == 'x':
3767
3958
  current = ax.get_xlabel()
3768
3959
  print(f"Current x-axis title: '{current}'")
3769
- new_title = input("Enter new x-axis title (q=cancel): ")
3960
+ new_title = _safe_input("Enter new x-axis title (q=cancel): ")
3770
3961
  if new_title and new_title.lower() != 'q':
3771
3962
  try:
3772
3963
  push_state("rename-x")
@@ -3785,7 +3976,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3785
3976
  elif sub == 'ly':
3786
3977
  current = ax.get_ylabel()
3787
3978
  print(f"Current left y-axis title: '{current}'")
3788
- new_title = input("Enter new left y-axis title (q=cancel): ")
3979
+ new_title = _safe_input("Enter new left y-axis title (q=cancel): ")
3789
3980
  if new_title and new_title.lower() != 'q':
3790
3981
  try:
3791
3982
  push_state("rename-ly")
@@ -3799,7 +3990,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3799
3990
  elif sub == 'ry':
3800
3991
  current = ax2.get_ylabel()
3801
3992
  print(f"Current right y-axis title: '{current}'")
3802
- new_title = input("Enter new right y-axis title (q=cancel): ")
3993
+ new_title = _safe_input("Enter new right y-axis title (q=cancel): ")
3803
3994
  if new_title and new_title.lower() != 'q':
3804
3995
  try:
3805
3996
  push_state("rename-ry")
@@ -3819,7 +4010,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3819
4010
  while True:
3820
4011
  current_xlim = ax.get_xlim()
3821
4012
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3822
- 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()
3823
4014
  if not rng or rng.lower() == 'q':
3824
4015
  break
3825
4016
  if rng.lower() == 'w':
@@ -3827,7 +4018,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3827
4018
  while True:
3828
4019
  current_xlim = ax.get_xlim()
3829
4020
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3830
- 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()
3831
4022
  if not val or val.lower() == 'q':
3832
4023
  break
3833
4024
  try:
@@ -3854,7 +4045,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3854
4045
  while True:
3855
4046
  current_xlim = ax.get_xlim()
3856
4047
  print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
3857
- 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()
3858
4049
  if not val or val.lower() == 'q':
3859
4050
  break
3860
4051
  try:
@@ -3917,7 +4108,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3917
4108
  elif key == 'y':
3918
4109
  while True:
3919
4110
  print("Y-ranges: ly=left axis, ry=right axis, q=back")
3920
- ycmd = input("Y> ").strip().lower()
4111
+ ycmd = _safe_input("Y> ").strip().lower()
3921
4112
  if not ycmd:
3922
4113
  continue
3923
4114
  if ycmd == 'q':
@@ -3926,7 +4117,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3926
4117
  while True:
3927
4118
  current_ylim = ax.get_ylim()
3928
4119
  print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3929
- rng = input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4120
+ rng = _safe_input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
3930
4121
  if not rng or rng.lower() == 'q':
3931
4122
  break
3932
4123
  if rng.lower() == 'w':
@@ -3934,7 +4125,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3934
4125
  while True:
3935
4126
  current_ylim = ax.get_ylim()
3936
4127
  print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3937
- val = input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4128
+ val = _safe_input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3938
4129
  if not val or val.lower() == 'q':
3939
4130
  break
3940
4131
  try:
@@ -3961,7 +4152,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3961
4152
  while True:
3962
4153
  current_ylim = ax.get_ylim()
3963
4154
  print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3964
- val = input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4155
+ val = _safe_input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3965
4156
  if not val or val.lower() == 'q':
3966
4157
  break
3967
4158
  try:
@@ -4029,7 +4220,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4029
4220
  break
4030
4221
  current_ylim = ax2.get_ylim()
4031
4222
  print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4032
- rng = input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4223
+ rng = _safe_input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
4033
4224
  if not rng or rng.lower() == 'q':
4034
4225
  break
4035
4226
  if rng.lower() == 'w':
@@ -4037,7 +4228,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4037
4228
  while True:
4038
4229
  current_ylim = ax2.get_ylim()
4039
4230
  print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4040
- val = input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4231
+ val = _safe_input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
4041
4232
  if not val or val.lower() == 'q':
4042
4233
  break
4043
4234
  try:
@@ -4064,7 +4255,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4064
4255
  while True:
4065
4256
  current_ylim = ax2.get_ylim()
4066
4257
  print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
4067
- val = input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4258
+ val = _safe_input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
4068
4259
  if not val or val.lower() == 'q':
4069
4260
  break
4070
4261
  try: