batplot 1.7.23__py3-none-any.whl → 1.7.25__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.
@@ -69,6 +69,27 @@ def _legend_no_frame(ax, *args, **kwargs):
69
69
  pass
70
70
  return leg
71
71
 
72
+
73
+ def _visible_handles_labels(ax, ax2):
74
+ """Return handles/labels for visible artists only."""
75
+ try:
76
+ h1, l1 = ax.get_legend_handles_labels()
77
+ except Exception:
78
+ h1, l1 = [], []
79
+ try:
80
+ h2, l2 = ax2.get_legend_handles_labels()
81
+ except Exception:
82
+ h2, l2 = [], []
83
+ H, L = [], []
84
+ for h, l in list(zip(h1, l1)) + list(zip(h2, l2)):
85
+ try:
86
+ if hasattr(h, 'get_visible') and not h.get_visible():
87
+ continue
88
+ except Exception:
89
+ pass
90
+ H.append(h); L.append(l)
91
+ return H, L
92
+
72
93
  def _colorize_menu(text):
73
94
  """Colorize menu items: command in cyan, colon in white, description in default."""
74
95
  if ':' not in text:
@@ -79,6 +100,46 @@ def _colorize_menu(text):
79
100
  return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
80
101
 
81
102
 
103
+ def _color_of(artist):
104
+ """Return a representative color for a Line2D/PathCollection."""
105
+ try:
106
+ if artist is None:
107
+ return None
108
+ if hasattr(artist, 'get_color'):
109
+ c = artist.get_color()
110
+ if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
111
+ return c[0]
112
+ return c
113
+ if hasattr(artist, 'get_facecolors'):
114
+ arr = artist.get_facecolors()
115
+ if arr is not None and len(arr):
116
+ from matplotlib.colors import to_hex
117
+ return to_hex(arr[0])
118
+ except Exception:
119
+ return None
120
+ return None
121
+
122
+
123
+ def _get_legend_title(fig, default: Optional[str] = None) -> Optional[str]:
124
+ """Fetch stored legend title, falling back to current legend text or None."""
125
+ try:
126
+ title = getattr(fig, '_cpc_legend_title', None)
127
+ if isinstance(title, str) and title:
128
+ return title
129
+ except Exception:
130
+ pass
131
+ try:
132
+ for ax in getattr(fig, 'axes', []):
133
+ leg = ax.get_legend()
134
+ if leg is not None:
135
+ t = leg.get_title().get_text()
136
+ if t:
137
+ return t
138
+ except Exception:
139
+ pass
140
+ return default
141
+
142
+
82
143
  def _colorize_prompt(text):
83
144
  """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
84
145
  import re
@@ -175,7 +236,7 @@ def _print_menu():
175
236
  " v: show/hide files",
176
237
  ]
177
238
  col2 = [
178
- "r: rename titles",
239
+ "r: rename",
179
240
  "x: x range",
180
241
  "y: y ranges",
181
242
  ]
@@ -222,9 +283,22 @@ def _print_file_list(file_data, current_idx):
222
283
  print()
223
284
 
224
285
 
225
- def _rebuild_legend(ax, ax2, file_data):
226
- """Rebuild legend from all visible files."""
286
+ def _rebuild_legend(ax, ax2, file_data, preserve_position=True):
287
+ """Rebuild legend from all visible files.
288
+
289
+ Args:
290
+ preserve_position: If True, preserve legend position after rebuilding.
291
+ """
227
292
  try:
293
+ fig = ax.figure
294
+ # Get stored position before rebuilding
295
+ xy_in = None
296
+ if preserve_position:
297
+ try:
298
+ xy_in = getattr(fig, '_cpc_legend_xy_in', None)
299
+ except Exception:
300
+ pass
301
+
228
302
  h1, l1 = ax.get_legend_handles_labels()
229
303
  h2, l2 = ax2.get_legend_handles_labels()
230
304
  # Filter to only visible items
@@ -233,8 +307,22 @@ def _rebuild_legend(ax, ax2, file_data):
233
307
  if h.get_visible():
234
308
  h_all.append(h)
235
309
  l_all.append(l)
310
+
236
311
  if h_all:
237
- _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0)
312
+ # Get legend title (None if not set, to avoid showing "Legend")
313
+ leg_title = _get_legend_title(fig, default=None)
314
+
315
+ if xy_in is not None and preserve_position:
316
+ # Use stored position
317
+ try:
318
+ fw, fh = fig.get_size_inches()
319
+ fx = 0.5 + float(xy_in[0]) / float(fw)
320
+ fy = 0.5 + float(xy_in[1]) / float(fh)
321
+ _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)
322
+ except Exception:
323
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
324
+ else:
325
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=leg_title)
238
326
  else:
239
327
  leg = ax.get_legend()
240
328
  if leg:
@@ -298,24 +386,35 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
298
386
  return None
299
387
  return None
300
388
 
301
- # Current tick visibility (best-effort from axes)
389
+ def _label_visible(lbl):
390
+ try:
391
+ return bool(lbl.get_visible()) and bool(lbl.get_text())
392
+ except Exception:
393
+ return bool(lbl.get_text()) if hasattr(lbl, 'get_text') else False
394
+
395
+ # Current tick visibility (prefer persisted WASD state when available)
302
396
  tick_vis = {
303
- 'bx': True,
304
- 'tx': False,
305
- 'ly': True,
306
- 'ry': True,
307
- 'mbx': False,
308
- 'mtx': False,
309
- 'mly': False,
310
- 'mry': False,
397
+ 'bx': True, 'tx': False, 'ly': True, 'ry': True,
398
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
311
399
  }
312
400
  try:
313
- # Infer from current axes state
314
- import matplotlib as _mpl
315
- tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
316
- tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
317
- tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
318
- tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
401
+ wasd_from_fig = getattr(fig, '_cpc_wasd_state', None)
402
+ if isinstance(wasd_from_fig, dict) and wasd_from_fig:
403
+ # Use stored state (authoritative)
404
+ tick_vis['bx'] = bool(wasd_from_fig.get('bottom', {}).get('labels', True))
405
+ tick_vis['tx'] = bool(wasd_from_fig.get('top', {}).get('labels', False))
406
+ tick_vis['ly'] = bool(wasd_from_fig.get('left', {}).get('labels', True))
407
+ tick_vis['ry'] = bool(wasd_from_fig.get('right', {}).get('labels', True))
408
+ tick_vis['mbx'] = bool(wasd_from_fig.get('bottom', {}).get('minor', False))
409
+ tick_vis['mtx'] = bool(wasd_from_fig.get('top', {}).get('minor', False))
410
+ tick_vis['mly'] = bool(wasd_from_fig.get('left', {}).get('minor', False))
411
+ tick_vis['mry'] = bool(wasd_from_fig.get('right', {}).get('minor', False))
412
+ else:
413
+ # Infer from current axes state
414
+ tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
415
+ tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
416
+ tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
417
+ tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
319
418
  except Exception:
320
419
  pass
321
420
 
@@ -333,36 +432,38 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
333
432
  except Exception:
334
433
  return False
335
434
 
336
- wasd_state = {
337
- 'bottom': {
338
- 'spine': _get_spine_visible(ax, 'bottom'),
339
- 'ticks': bool(tick_vis.get('bx', True)),
340
- 'minor': bool(tick_vis.get('mbx', False)),
341
- 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
342
- 'title': bool(ax.get_xlabel()) # bottom x title
343
- },
344
- 'top': {
345
- 'spine': _get_spine_visible(ax, 'top'),
346
- 'ticks': bool(tick_vis.get('tx', False)),
347
- 'minor': bool(tick_vis.get('mtx', False)),
348
- 'labels': bool(tick_vis.get('tx', False)),
349
- 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
350
- },
351
- 'left': {
352
- 'spine': _get_spine_visible(ax, 'left'),
353
- 'ticks': bool(tick_vis.get('ly', True)),
354
- 'minor': bool(tick_vis.get('mly', False)),
355
- 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
356
- 'title': bool(ax.get_ylabel()) # left y title
357
- },
358
- 'right': {
359
- 'spine': _get_spine_visible(ax2, 'right'),
360
- 'ticks': bool(tick_vis.get('ry', True)),
361
- 'minor': bool(tick_vis.get('mry', False)),
362
- 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
363
- 'title': bool(ax2.get_ylabel()) # right y title
364
- },
365
- }
435
+ wasd_state = getattr(fig, '_cpc_wasd_state', None)
436
+ if not isinstance(wasd_state, dict) or not wasd_state:
437
+ wasd_state = {
438
+ 'bottom': {
439
+ 'spine': _get_spine_visible(ax, 'bottom'),
440
+ 'ticks': bool(tick_vis.get('bx', True)),
441
+ 'minor': bool(tick_vis.get('mbx', False)),
442
+ 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
443
+ 'title': bool(ax.get_xlabel()) # bottom x title
444
+ },
445
+ 'top': {
446
+ 'spine': _get_spine_visible(ax, 'top'),
447
+ 'ticks': bool(tick_vis.get('tx', False)),
448
+ 'minor': bool(tick_vis.get('mtx', False)),
449
+ 'labels': bool(tick_vis.get('tx', False)),
450
+ 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
451
+ },
452
+ 'left': {
453
+ 'spine': _get_spine_visible(ax, 'left'),
454
+ 'ticks': bool(tick_vis.get('ly', True)),
455
+ 'minor': bool(tick_vis.get('mly', False)),
456
+ 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
457
+ 'title': _label_visible(ax.yaxis.label) # left y title
458
+ },
459
+ 'right': {
460
+ 'spine': _get_spine_visible(ax2, 'right'),
461
+ 'ticks': bool(tick_vis.get('ry', True)),
462
+ 'minor': bool(tick_vis.get('mry', False)),
463
+ 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
464
+ 'title': _label_visible(ax2.yaxis.label) # right y title respects visibility
465
+ },
466
+ }
366
467
 
367
468
  # Capture legend state
368
469
  legend_visible = False
@@ -398,7 +499,8 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
398
499
  'font': {'family': fam0, 'size': fsize},
399
500
  'legend': {
400
501
  'visible': legend_visible,
401
- 'position_inches': legend_xy_in # [x, y] offset from canvas center in inches
502
+ 'position_inches': legend_xy_in, # [x, y] offset from canvas center in inches
503
+ 'title': _get_legend_title(fig),
402
504
  },
403
505
  'ticks': {
404
506
  'widths': {
@@ -474,8 +576,29 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
474
576
  'discharge_color': _color_of(f.get('sc_discharge')),
475
577
  'efficiency_color': _color_of(f.get('sc_eff')),
476
578
  }
579
+ # Save legend labels
580
+ try:
581
+ sc_chg = f.get('sc_charge')
582
+ sc_dchg = f.get('sc_discharge')
583
+ sc_eff = f.get('sc_eff')
584
+ if sc_chg and hasattr(sc_chg, 'get_label'):
585
+ file_info['charge_label'] = sc_chg.get_label() or ''
586
+ if sc_dchg and hasattr(sc_dchg, 'get_label'):
587
+ file_info['discharge_label'] = sc_dchg.get_label() or ''
588
+ if sc_eff and hasattr(sc_eff, 'get_label'):
589
+ file_info['efficiency_label'] = sc_eff.get_label() or ''
590
+ except Exception:
591
+ pass
477
592
  multi_files.append(file_info)
478
593
  cfg['multi_files'] = multi_files
594
+ else:
595
+ # Single file mode: save legend labels
596
+ try:
597
+ cfg['series']['charge']['label'] = sc_charge.get_label() if hasattr(sc_charge, 'get_label') else ''
598
+ cfg['series']['discharge']['label'] = sc_discharge.get_label() if hasattr(sc_discharge, 'get_label') else ''
599
+ cfg['series']['efficiency']['label'] = sc_eff.get_label() if hasattr(sc_eff, 'get_label') else ''
600
+ except Exception:
601
+ pass
479
602
 
480
603
  return cfg
481
604
 
@@ -671,6 +794,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
671
794
  if leg_cfg:
672
795
  leg_visible = leg_cfg.get('visible', True)
673
796
  leg_xy_in = leg_cfg.get('position_inches')
797
+ if 'title' in leg_cfg:
798
+ fig._cpc_legend_title = leg_cfg.get('title') or _get_legend_title(fig)
674
799
  if leg_xy_in is not None:
675
800
  fig._cpc_legend_xy_in = _sanitize_legend_offset(tuple(leg_xy_in))
676
801
  leg = ax.get_legend()
@@ -685,6 +810,11 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
685
810
  tk = cfg.get('ticks', {})
686
811
  # Try wasd_state first (version 2), fall back to visibility dict (version 1)
687
812
  wasd = cfg.get('wasd_state', {})
813
+ if isinstance(wasd, dict) and wasd:
814
+ try:
815
+ setattr(fig, '_cpc_wasd_state', wasd)
816
+ except Exception:
817
+ pass
688
818
  if wasd:
689
819
  # Use WASD state (20 parameters)
690
820
  bx = bool(wasd.get('bottom', {}).get('labels', True))
@@ -711,6 +841,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
711
841
  ax.tick_params(axis='x', bottom=bx, labelbottom=bx, top=tx, labeltop=tx)
712
842
  ax.tick_params(axis='y', left=ly, labelleft=ly)
713
843
  ax2.tick_params(axis='y', right=ry, labelright=ry)
844
+ try:
845
+ ax.xaxis.label.set_visible(bool(wasd.get('bottom', {}).get('title', True)) if wasd else bx)
846
+ ax.yaxis.label.set_visible(bool(wasd.get('left', {}).get('title', True)) if wasd else ly)
847
+ ax2.yaxis.label.set_visible(bool(wasd.get('right', {}).get('title', True)) if wasd else ry)
848
+ except Exception:
849
+ pass
714
850
  # Minor ticks
715
851
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator, NullLocator
716
852
  if mbx or mtx:
@@ -906,6 +1042,58 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
906
1042
  _ui_position_right_ylabel(ax2, fig, tick_state)
907
1043
  except Exception:
908
1044
  pass
1045
+ # Restore legend labels
1046
+ try:
1047
+ if is_multi_file and file_data:
1048
+ multi_files = cfg.get('multi_files', [])
1049
+ if multi_files and len(multi_files) == len(file_data):
1050
+ for i, f_info in enumerate(multi_files):
1051
+ if i < len(file_data):
1052
+ f = file_data[i]
1053
+ # Restore legend labels
1054
+ if 'charge_label' in f_info and f.get('sc_charge'):
1055
+ try:
1056
+ f['sc_charge'].set_label(f_info['charge_label'])
1057
+ except Exception:
1058
+ pass
1059
+ if 'discharge_label' in f_info and f.get('sc_discharge'):
1060
+ try:
1061
+ f['sc_discharge'].set_label(f_info['discharge_label'])
1062
+ except Exception:
1063
+ pass
1064
+ if 'efficiency_label' in f_info and f.get('sc_eff'):
1065
+ try:
1066
+ f['sc_eff'].set_label(f_info['efficiency_label'])
1067
+ except Exception:
1068
+ pass
1069
+ # Update filename if present
1070
+ if 'filename' in f_info:
1071
+ f['filename'] = f_info['filename']
1072
+ else:
1073
+ # Single file mode: restore legend labels
1074
+ s = cfg.get('series', {})
1075
+ ch = s.get('charge', {})
1076
+ dh = s.get('discharge', {})
1077
+ ef = s.get('efficiency', {})
1078
+ if 'label' in ch and hasattr(sc_charge, 'set_label'):
1079
+ try:
1080
+ sc_charge.set_label(ch['label'])
1081
+ except Exception:
1082
+ pass
1083
+ if 'label' in dh and hasattr(sc_discharge, 'set_label'):
1084
+ try:
1085
+ sc_discharge.set_label(dh['label'])
1086
+ except Exception:
1087
+ pass
1088
+ if 'label' in ef and hasattr(sc_eff, 'set_label'):
1089
+ try:
1090
+ sc_eff.set_label(ef['label'])
1091
+ except Exception:
1092
+ pass
1093
+ # Rebuild legend after restoring labels
1094
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1095
+ except Exception:
1096
+ pass
909
1097
  try:
910
1098
  fig.canvas.draw_idle()
911
1099
  except Exception:
@@ -1062,6 +1250,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1062
1250
  'mly': False, # minor left y-axis ticks - hidden by default
1063
1251
  'mry': False, # minor right y-axis ticks - hidden by default
1064
1252
  }
1253
+ try:
1254
+ saved_wasd = getattr(fig, '_cpc_wasd_state', None)
1255
+ if isinstance(saved_wasd, dict) and saved_wasd:
1256
+ tick_state['bx'] = bool(saved_wasd.get('bottom', {}).get('labels', tick_state['bx']))
1257
+ tick_state['tx'] = bool(saved_wasd.get('top', {}).get('labels', tick_state['tx']))
1258
+ tick_state['ly'] = bool(saved_wasd.get('left', {}).get('labels', tick_state['ly']))
1259
+ tick_state['ry'] = bool(saved_wasd.get('right', {}).get('labels', tick_state['ry']))
1260
+ tick_state['mbx'] = bool(saved_wasd.get('bottom', {}).get('minor', tick_state['mbx']))
1261
+ tick_state['mtx'] = bool(saved_wasd.get('top', {}).get('minor', tick_state['mtx']))
1262
+ tick_state['mly'] = bool(saved_wasd.get('left', {}).get('minor', tick_state['mly']))
1263
+ tick_state['mry'] = bool(saved_wasd.get('right', {}).get('minor', tick_state['mry']))
1264
+ except Exception:
1265
+ pass
1065
1266
 
1066
1267
  # --- Undo stack using style snapshots ---
1067
1268
  state_history = [] # list of cfg dicts
@@ -1138,6 +1339,8 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1138
1339
  try:
1139
1340
  # Apply shared visibility to primary ax; then adjust twin for right side
1140
1341
  _ui_update_tick_visibility(ax, tick_state)
1342
+ # Ensure left axis ticks/labels don't appear on right axis
1343
+ ax.tick_params(axis='y', right=False, labelright=False)
1141
1344
  # Right axis tick params follow r_* keys
1142
1345
  ax2.tick_params(axis='y',
1143
1346
  right=tick_state.get('r_ticks', tick_state.get('ry', False)),
@@ -1196,8 +1399,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1196
1399
  """Reapply legend position using stored inches offset relative to canvas center."""
1197
1400
  try:
1198
1401
  xy_in = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
1199
- leg = ax.get_legend()
1200
- if xy_in is None or leg is None:
1402
+ if xy_in is None:
1201
1403
  return
1202
1404
  # Compute figure-fraction anchor from inches
1203
1405
  fw, fh = fig.get_size_inches()
@@ -1205,11 +1407,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1205
1407
  return
1206
1408
  fx = 0.5 + float(xy_in[0]) / float(fw)
1207
1409
  fy = 0.5 + float(xy_in[1]) / float(fh)
1208
- # Use current handles/labels
1209
- h1, l1 = ax.get_legend_handles_labels()
1210
- h2, l2 = ax2.get_legend_handles_labels()
1211
- if h1 or h2:
1212
- _legend_no_frame(ax, h1 + h2, l1 + l2, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
1410
+ # Use current visible handles/labels
1411
+ H, L = _visible_handles_labels(ax, ax2)
1412
+ if H:
1413
+ _legend_no_frame(
1414
+ ax,
1415
+ H,
1416
+ L,
1417
+ loc='center',
1418
+ bbox_to_anchor=(fx, fy),
1419
+ bbox_transform=fig.transFigure,
1420
+ borderaxespad=1.0,
1421
+ title=_get_legend_title(fig),
1422
+ )
1213
1423
  except Exception:
1214
1424
  pass
1215
1425
 
@@ -1227,9 +1437,6 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1227
1437
  pass
1228
1438
 
1229
1439
  _print_menu()
1230
- if is_multi_file:
1231
- print(f"\n[Multi-file mode: {len(file_data)} files loaded]")
1232
- _print_file_list(file_data, current_file_idx)
1233
1440
 
1234
1441
  while True:
1235
1442
  try:
@@ -1680,12 +1887,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1680
1887
  else:
1681
1888
  print(f" {i}: {fname}")
1682
1889
 
1683
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1890
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
1891
+ 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()
1893
+ else:
1894
+ fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1684
1895
  if not fname or fname.lower() == 'q':
1685
1896
  _print_menu(); continue
1686
1897
 
1898
+ # Check for 'o' option
1899
+ if fname.lower() == 'o':
1900
+ if not last_figure_path:
1901
+ print("No previous export found.")
1902
+ _print_menu(); continue
1903
+ if not os.path.exists(last_figure_path):
1904
+ print(f"Previous export file not found: {last_figure_path}")
1905
+ _print_menu(); continue
1906
+ yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1907
+ if yn != 'y':
1908
+ _print_menu(); continue
1909
+ target = last_figure_path
1687
1910
  # Check if user selected a number
1688
- if fname.isdigit() and files:
1911
+ elif fname.isdigit() and files:
1689
1912
  idx = int(fname)
1690
1913
  if 1 <= idx <= len(files):
1691
1914
  name = files[idx-1]
@@ -1732,8 +1955,52 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1732
1955
  pass
1733
1956
 
1734
1957
  # Export the figure
1735
- fig.savefig(target, bbox_inches='tight')
1958
+ _, _ext = os.path.splitext(target)
1959
+ if _ext.lower() == '.svg':
1960
+ # Temporarily force transparent patches so SVG background stays transparent
1961
+ try:
1962
+ _fig_fc = fig.get_facecolor()
1963
+ except Exception:
1964
+ _fig_fc = None
1965
+ try:
1966
+ _ax_fc = ax.get_facecolor()
1967
+ except Exception:
1968
+ _ax_fc = None
1969
+ try:
1970
+ _ax2_fc = ax2.get_facecolor()
1971
+ except Exception:
1972
+ _ax2_fc = None
1973
+ try:
1974
+ if getattr(fig, 'patch', None) is not None:
1975
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
1976
+ if getattr(ax, 'patch', None) is not None:
1977
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
1978
+ if getattr(ax2, 'patch', None) is not None:
1979
+ ax2.patch.set_alpha(0.0); ax2.patch.set_facecolor('none')
1980
+ except Exception:
1981
+ pass
1982
+ try:
1983
+ fig.savefig(target, bbox_inches='tight', transparent=True, facecolor='none', edgecolor='none')
1984
+ finally:
1985
+ try:
1986
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
1987
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
1988
+ except Exception:
1989
+ pass
1990
+ try:
1991
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
1992
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
1993
+ except Exception:
1994
+ pass
1995
+ try:
1996
+ if _ax2_fc is not None and getattr(ax2, 'patch', None) is not None:
1997
+ ax2.patch.set_alpha(1.0); ax2.patch.set_facecolor(_ax2_fc)
1998
+ except Exception:
1999
+ pass
2000
+ else:
2001
+ fig.savefig(target, bbox_inches='tight')
1736
2002
  print(f"Exported figure to {target}")
2003
+ fig._last_figure_export_path = target
1737
2004
 
1738
2005
  # Restore original labels
1739
2006
  if is_multi_file and original_labels:
@@ -1750,6 +2017,58 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1750
2017
  # Save CPC session (.pkl) with all data and styles
1751
2018
  try:
1752
2019
  from .session import dump_cpc_session
2020
+ # Sync current tick/title visibility (including minors) into stored WASD state before save
2021
+ try:
2022
+ wasd = getattr(fig, '_cpc_wasd_state', {})
2023
+ if not isinstance(wasd, dict):
2024
+ wasd = {}
2025
+ # bottom
2026
+ w = wasd.setdefault('bottom', {})
2027
+ w['ticks'] = bool(tick_state.get('b_ticks', tick_state.get('bx', True)))
2028
+ w['labels'] = bool(tick_state.get('b_labels', tick_state.get('bx', True)))
2029
+ w['minor'] = bool(tick_state.get('mbx', False))
2030
+ w['title'] = bool(ax.xaxis.label.get_visible())
2031
+ try:
2032
+ sp = ax.spines.get('bottom')
2033
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2034
+ except Exception:
2035
+ pass
2036
+ # top
2037
+ w = wasd.setdefault('top', {})
2038
+ w['ticks'] = bool(tick_state.get('t_ticks', tick_state.get('tx', False)))
2039
+ w['labels'] = bool(tick_state.get('t_labels', tick_state.get('tx', False)))
2040
+ w['minor'] = bool(tick_state.get('mtx', False))
2041
+ w['title'] = bool(getattr(ax, '_top_xlabel_on', False))
2042
+ try:
2043
+ sp = ax.spines.get('top')
2044
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', False)
2045
+ except Exception:
2046
+ pass
2047
+ # left
2048
+ w = wasd.setdefault('left', {})
2049
+ w['ticks'] = bool(tick_state.get('l_ticks', tick_state.get('ly', True)))
2050
+ w['labels'] = bool(tick_state.get('l_labels', tick_state.get('ly', True)))
2051
+ w['minor'] = bool(tick_state.get('mly', False))
2052
+ w['title'] = bool(ax.yaxis.label.get_visible())
2053
+ try:
2054
+ sp = ax.spines.get('left')
2055
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2056
+ except Exception:
2057
+ pass
2058
+ # right
2059
+ w = wasd.setdefault('right', {})
2060
+ w['ticks'] = bool(tick_state.get('r_ticks', tick_state.get('ry', True)))
2061
+ w['labels'] = bool(tick_state.get('r_labels', tick_state.get('ry', True)))
2062
+ w['minor'] = bool(tick_state.get('mry', False))
2063
+ w['title'] = bool(ax2.yaxis.label.get_visible() if ax2 is not None else False)
2064
+ try:
2065
+ sp = ax2.spines.get('right') if ax2 is not None else None
2066
+ w['spine'] = bool(sp.get_visible()) if sp else w.get('spine', True)
2067
+ except Exception:
2068
+ pass
2069
+ setattr(fig, '_cpc_wasd_state', wasd)
2070
+ except Exception:
2071
+ pass
1753
2072
  folder = choose_save_path(file_paths, purpose="CPC session save")
1754
2073
  if not folder:
1755
2074
  _print_menu(); continue
@@ -1767,10 +2086,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1767
2086
  print(f" {i}: {f} ({timestamp})")
1768
2087
  else:
1769
2088
  print(f" {i}: {f}")
1770
- prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2089
+ last_session_path = getattr(fig, '_last_session_save_path', None)
2090
+ if last_session_path:
2091
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2092
+ else:
2093
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1771
2094
  choice = input(prompt).strip()
1772
2095
  if not choice or choice.lower() == 'q':
1773
2096
  _print_menu(); continue
2097
+ if choice.lower() == 'o':
2098
+ # Overwrite last saved session
2099
+ if not last_session_path:
2100
+ print("No previous save found.")
2101
+ _print_menu(); continue
2102
+ if not os.path.exists(last_session_path):
2103
+ print(f"Previous save file not found: {last_session_path}")
2104
+ _print_menu(); continue
2105
+ yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2106
+ if yn != 'y':
2107
+ _print_menu(); continue
2108
+ 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)
2109
+ print(f"Overwritten session to {last_session_path}")
2110
+ _print_menu(); continue
1774
2111
  if choice.isdigit() and files:
1775
2112
  idx = int(choice)
1776
2113
  if 1 <= idx <= len(files):
@@ -1779,10 +2116,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1779
2116
  if yn != 'y':
1780
2117
  _print_menu(); continue
1781
2118
  target = os.path.join(folder, name)
2119
+ 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)
2120
+ fig._last_session_save_path = target
2121
+ _print_menu(); continue
1782
2122
  else:
1783
2123
  print("Invalid number.")
1784
2124
  _print_menu(); continue
1785
- else:
2125
+ if choice.lower() != 'o':
1786
2126
  name = choice
1787
2127
  root, ext = os.path.splitext(name)
1788
2128
  if ext == '':
@@ -1792,7 +2132,8 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1792
2132
  yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
1793
2133
  if yn != 'y':
1794
2134
  _print_menu(); continue
1795
- 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)
2135
+ 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)
2136
+ fig._last_session_save_path = target
1796
2137
  except Exception as e:
1797
2138
  print(f"Save failed: {e}")
1798
2139
  _print_menu(); continue
@@ -1916,11 +2257,44 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1916
2257
  else:
1917
2258
  print(f" {_i}: {fname}")
1918
2259
 
1919
- sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2260
+ last_style_path = getattr(fig, '_last_style_export_path', None)
2261
+ if last_style_path:
2262
+ sub = input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
2263
+ else:
2264
+ sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1920
2265
  if sub == 'q':
1921
2266
  break
1922
2267
  if sub == 'r' or sub == '':
1923
2268
  continue
2269
+ if sub == 'o':
2270
+ # Overwrite last exported style file
2271
+ if not last_style_path:
2272
+ print("No previous export found.")
2273
+ continue
2274
+ if not os.path.exists(last_style_path):
2275
+ print(f"Previous export file not found: {last_style_path}")
2276
+ continue
2277
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2278
+ if yn != 'y':
2279
+ continue
2280
+ # Rebuild config based on current state
2281
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2282
+ # Determine if last export was style-only or style+geometry
2283
+ try:
2284
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2285
+ old_cfg = json.load(f)
2286
+ if old_cfg.get('kind') == 'cpc_style_geom':
2287
+ snap['kind'] = 'cpc_style_geom'
2288
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2289
+ else:
2290
+ snap['kind'] = 'cpc_style'
2291
+ except Exception:
2292
+ snap['kind'] = 'cpc_style'
2293
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2294
+ json.dump(snap, f, indent=2)
2295
+ print(f"Overwritten style to {last_style_path}")
2296
+ style_menu_active = False
2297
+ break
1924
2298
  if sub == 'e':
1925
2299
  # Ask for ps or psg
1926
2300
  print("Export options:")
@@ -1978,10 +2352,42 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1978
2352
  print(f" {i}: {fname} ({timestamp})")
1979
2353
  else:
1980
2354
  print(f" {i}: {fname}")
1981
- choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
2355
+ if last_style_path:
2356
+ choice = input("Enter new filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
2357
+ else:
2358
+ choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
1982
2359
  if not choice or choice.lower() == 'q':
1983
2360
  print("Style export canceled.")
1984
2361
  continue
2362
+ if choice.lower() == 'o':
2363
+ # Overwrite last exported style file
2364
+ if not last_style_path:
2365
+ print("No previous export found.")
2366
+ continue
2367
+ if not os.path.exists(last_style_path):
2368
+ print(f"Previous export file not found: {last_style_path}")
2369
+ continue
2370
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2371
+ if yn != 'y':
2372
+ continue
2373
+ # Rebuild config based on current state
2374
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2375
+ # Determine if last export was style-only or style+geometry
2376
+ try:
2377
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2378
+ old_cfg = json.load(f)
2379
+ if old_cfg.get('kind') == 'cpc_style_geom':
2380
+ snap['kind'] = 'cpc_style_geom'
2381
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2382
+ else:
2383
+ snap['kind'] = 'cpc_style'
2384
+ except Exception:
2385
+ snap['kind'] = 'cpc_style'
2386
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2387
+ json.dump(snap, f, indent=2)
2388
+ print(f"Overwritten style to {last_style_path}")
2389
+ style_menu_active = False
2390
+ break
1985
2391
  target = None
1986
2392
  if choice.isdigit() and files:
1987
2393
  idx = int(choice)
@@ -2011,6 +2417,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2011
2417
  with open(target, 'w', encoding='utf-8') as f:
2012
2418
  json.dump(snap, f, indent=2)
2013
2419
  print(f"Exported CPC style to {target}")
2420
+ fig._last_style_export_path = target
2014
2421
  style_menu_active = False # Exit style submenu and return to main menu
2015
2422
  break
2016
2423
  else:
@@ -2065,11 +2472,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2065
2472
  # Toggle efficiency visibility on the right axis
2066
2473
  try:
2067
2474
  push_state("toggle-eff")
2068
- vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2069
- new_vis = not vis
2070
2475
 
2071
- # 1. Hide/show efficiency points
2072
- sc_eff.set_visible(new_vis)
2476
+ # Determine current visibility state (check if any efficiency is visible)
2477
+ if is_multi_file:
2478
+ # In multi-file mode, check if any efficiency is visible
2479
+ 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'))
2480
+ new_vis = not any_eff_visible
2481
+ else:
2482
+ # Single file mode
2483
+ vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2484
+ new_vis = not vis
2485
+
2486
+ # 1. Hide/show efficiency points (all files in multi-file mode)
2487
+ if is_multi_file:
2488
+ for f in file_data:
2489
+ eff_sc = f.get('sc_eff')
2490
+ if eff_sc is not None:
2491
+ try:
2492
+ eff_sc.set_visible(new_vis)
2493
+ except Exception:
2494
+ pass
2495
+ else:
2496
+ sc_eff.set_visible(new_vis)
2073
2497
 
2074
2498
  # 2. Hide/show right y-axis title
2075
2499
  try:
@@ -2085,68 +2509,42 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2085
2509
  except Exception:
2086
2510
  pass
2087
2511
 
2088
- # 4. Rebuild legend to remove/add efficiency entry
2089
- try:
2090
- h1, l1 = ax.get_legend_handles_labels()
2091
- except Exception:
2092
- h1, l1 = [], []
2093
- try:
2094
- h2, l2 = ax2.get_legend_handles_labels()
2095
- except Exception:
2096
- h2, l2 = [], []
2097
-
2098
- # Filter out efficiency entry if hidden
2099
- eff_label = None
2512
+ # Persist WASD state so save/load and styles honor the toggle
2100
2513
  try:
2101
- eff_label = sc_eff.get_label()
2514
+ wasd = getattr(fig, '_cpc_wasd_state', None)
2515
+ if not isinstance(wasd, dict):
2516
+ wasd = {
2517
+ 'top': {'spine': bool(ax.spines.get('top').get_visible()) if ax.spines.get('top') else False,
2518
+ 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
2519
+ 'minor': bool(tick_state.get('mtx', False)),
2520
+ 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
2521
+ 'title': bool(getattr(ax, '_top_xlabel_on', False))},
2522
+ 'bottom': {'spine': bool(ax.spines.get('bottom').get_visible()) if ax.spines.get('bottom') else True,
2523
+ 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
2524
+ 'minor': bool(tick_state.get('mbx', False)),
2525
+ 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
2526
+ 'title': bool(ax.xaxis.label.get_visible()) and bool(ax.get_xlabel())},
2527
+ 'left': {'spine': bool(ax.spines.get('left').get_visible()) if ax.spines.get('left') else True,
2528
+ 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
2529
+ 'minor': bool(tick_state.get('mly', False)),
2530
+ 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
2531
+ 'title': bool(ax.yaxis.label.get_visible()) and bool(ax.get_ylabel())},
2532
+ 'right': {'spine': bool(ax2.spines.get('right').get_visible()) if ax2.spines.get('right') else True,
2533
+ 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', True))),
2534
+ 'minor': bool(tick_state.get('mry', False)),
2535
+ 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', True))),
2536
+ 'title': bool(ax2.yaxis.label.get_visible()) and bool(ax2.get_ylabel())},
2537
+ }
2538
+ wasd.setdefault('right', {})
2539
+ wasd['right']['ticks'] = bool(new_vis)
2540
+ wasd['right']['labels'] = bool(new_vis)
2541
+ wasd['right']['title'] = bool(new_vis)
2542
+ setattr(fig, '_cpc_wasd_state', wasd)
2102
2543
  except Exception:
2103
2544
  pass
2104
2545
 
2105
- pairs1 = list(zip(h1, l1))
2106
- pairs2 = list(zip(h2, l2))
2107
-
2108
- def _keep(pair):
2109
- h, l = pair
2110
- # Drop invisible handles
2111
- try:
2112
- if hasattr(h, 'get_visible') and not h.get_visible():
2113
- return False
2114
- except Exception:
2115
- pass
2116
- # Drop the efficiency label when hidden
2117
- if eff_label and (l == eff_label) and not new_vis:
2118
- return False
2119
- return True
2120
-
2121
- vis_pairs1 = [p for p in pairs1 if _keep(p)]
2122
- vis_pairs2 = [p for p in pairs2 if _keep(p)]
2123
- H = [h for h, _ in vis_pairs1 + vis_pairs2]
2124
- L = [l for _, l in vis_pairs1 + vis_pairs2]
2125
-
2126
- if H:
2127
- try:
2128
- # Honor stored inch-based anchor if present; else fallback to 'best'
2129
- xy_in = getattr(fig, '_cpc_legend_xy_in', None)
2130
- if xy_in is not None:
2131
- try:
2132
- fw, fh = fig.get_size_inches()
2133
- fx = 0.5 + float(xy_in[0]) / float(fw)
2134
- fy = 0.5 + float(xy_in[1]) / float(fh)
2135
- _legend_no_frame(ax, H, L, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
2136
- except Exception:
2137
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2138
- else:
2139
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2140
- except Exception:
2141
- pass
2142
- else:
2143
- # No visible series: hide legend if present
2144
- try:
2145
- leg = ax.get_legend()
2146
- if leg is not None:
2147
- leg.set_visible(False)
2148
- except Exception:
2149
- pass
2546
+ # 4. Rebuild legend to remove/add efficiency entries (preserve position)
2547
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
2150
2548
 
2151
2549
  fig.canvas.draw_idle()
2152
2550
  except Exception:
@@ -2202,15 +2600,14 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2202
2600
  leg.set_visible(False)
2203
2601
  else:
2204
2602
  # Ensure a legend exists at the stored position
2205
- h1, l1 = ax.get_legend_handles_labels()
2206
- h2, l2 = ax2.get_legend_handles_labels()
2207
- if h1 or h2:
2603
+ H, L = _visible_handles_labels(ax, ax2)
2604
+ if H:
2208
2605
  offset = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
2209
2606
  if offset is not None:
2210
2607
  fig._cpc_legend_xy_in = offset
2211
2608
  _apply_legend_position()
2212
2609
  else:
2213
- _legend_no_frame(ax, h1 + h2, l1 + l2, loc='best', borderaxespad=1.0)
2610
+ _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2214
2611
  fig.canvas.draw_idle()
2215
2612
  except Exception:
2216
2613
  pass
@@ -3142,15 +3539,231 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3142
3539
  # Rename axis titles
3143
3540
  print("Tip: Use LaTeX/mathtext for special characters:")
3144
3541
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
3145
- print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3542
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3146
3543
  while True:
3147
- print("Rename titles: x=x-axis, ly=left y-axis, ry=right y-axis, q=back")
3544
+ print("Rename: x=x-axis, ly=left y-axis, ry=right y-axis, l=legend labels, q=back")
3148
3545
  sub = input("Rename> ").strip().lower()
3149
3546
  if not sub:
3150
3547
  continue
3151
3548
  if sub == 'q':
3152
3549
  break
3153
- if sub == 'x':
3550
+ if sub == 'l':
3551
+ # Rename legend labels (file name in legend)
3552
+ if not is_multi_file:
3553
+ # Single file mode: rename the default file
3554
+ current_file = file_data[0]
3555
+ sc_chg = current_file['sc_charge']
3556
+ sc_dchg = current_file['sc_discharge']
3557
+ sc_eff = current_file['sc_eff']
3558
+
3559
+ # Get current labels
3560
+ chg_label = sc_chg.get_label() or ''
3561
+ dchg_label = sc_dchg.get_label() or ''
3562
+ eff_label = sc_eff.get_label() or ''
3563
+
3564
+ # Extract base filename (everything before " charge", " discharge", or " efficiency")
3565
+ # Also handle patterns like "filename (Chg)", "filename (Dchg)", "filename (Eff)"
3566
+ base_name = current_file.get('filename', 'Data')
3567
+
3568
+ # Try to extract from labels
3569
+ import re
3570
+ for label in [chg_label, dchg_label, eff_label]:
3571
+ if label:
3572
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3573
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3574
+ if bracket_match:
3575
+ potential_base = bracket_match.group(1).strip()
3576
+ if potential_base:
3577
+ base_name = potential_base
3578
+ break
3579
+ else:
3580
+ # Try to extract from text suffix patterns
3581
+ for suffix in [' charge', ' discharge', ' efficiency']:
3582
+ if label.endswith(suffix):
3583
+ potential_base = label[:-len(suffix)].strip()
3584
+ if potential_base:
3585
+ base_name = potential_base
3586
+ break
3587
+ if base_name != current_file.get('filename', 'Data'):
3588
+ break
3589
+
3590
+ print(f"Current file name in legend: '{base_name}'")
3591
+ new_name = input("Enter new file name (q=cancel): ").strip()
3592
+ if new_name and new_name.lower() != 'q':
3593
+ try:
3594
+ push_state("rename-legend")
3595
+
3596
+ # Extract bracket content from original labels if present
3597
+ import re
3598
+ chg_bracket = ''
3599
+ dchg_bracket = ''
3600
+ eff_bracket = ''
3601
+
3602
+ # Check for bracket patterns in original labels
3603
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3604
+ if chg_match:
3605
+ chg_bracket = chg_match.group(1)
3606
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3607
+ if dchg_match:
3608
+ dchg_bracket = dchg_match.group(1)
3609
+ # Fix capitalization: Dchg -> DChg
3610
+ if dchg_bracket.lower() == 'dchg':
3611
+ dchg_bracket = 'DChg'
3612
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3613
+ if eff_match:
3614
+ eff_bracket = eff_match.group(1)
3615
+
3616
+ # If no brackets found, extract from label suffix or use defaults
3617
+ if not chg_bracket:
3618
+ # Try to extract from " charge" suffix
3619
+ if chg_label.endswith(' charge'):
3620
+ chg_bracket = 'Chg'
3621
+ else:
3622
+ chg_bracket = 'Chg'
3623
+ if not dchg_bracket:
3624
+ # Try to extract from " discharge" suffix
3625
+ if dchg_label.endswith(' discharge'):
3626
+ dchg_bracket = 'DChg'
3627
+ else:
3628
+ dchg_bracket = 'DChg'
3629
+ if not eff_bracket:
3630
+ # Try to extract from " efficiency" suffix
3631
+ if eff_label.endswith(' efficiency'):
3632
+ eff_bracket = 'Eff'
3633
+ else:
3634
+ eff_bracket = 'Eff'
3635
+
3636
+ # Build new labels with brackets preserved
3637
+ new_chg_label = f"{new_name} ({chg_bracket})"
3638
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
3639
+ new_eff_label = f"{new_name} ({eff_bracket})"
3640
+
3641
+ # Update labels
3642
+ sc_chg.set_label(new_chg_label)
3643
+ sc_dchg.set_label(new_dchg_label)
3644
+ sc_eff.set_label(new_eff_label)
3645
+
3646
+ # Update filename in file_data
3647
+ current_file['filename'] = new_name
3648
+
3649
+ # Rebuild legend (preserve position)
3650
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
3651
+ fig.canvas.draw_idle()
3652
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
3653
+ except Exception as e:
3654
+ print(f"Error: {e}")
3655
+ else:
3656
+ # Multi-file mode: show file list and let user select
3657
+ print("\nAvailable files:")
3658
+ _print_file_list(file_data, current_file_idx)
3659
+ file_choice = input("Enter file number to rename (q=cancel): ").strip()
3660
+ if file_choice and file_choice.lower() != 'q':
3661
+ try:
3662
+ file_idx = int(file_choice) - 1
3663
+ if 0 <= file_idx < len(file_data):
3664
+ current_file = file_data[file_idx]
3665
+ sc_chg = current_file['sc_charge']
3666
+ sc_dchg = current_file['sc_discharge']
3667
+ sc_eff = current_file['sc_eff']
3668
+
3669
+ # Get current labels
3670
+ chg_label = sc_chg.get_label() or ''
3671
+ dchg_label = sc_dchg.get_label() or ''
3672
+ eff_label = sc_eff.get_label() or ''
3673
+
3674
+ # Extract base filename
3675
+ base_name = current_file.get('filename', 'Data')
3676
+ import re
3677
+ for label in [chg_label, dchg_label, eff_label]:
3678
+ if label:
3679
+ # First try to extract from bracket pattern: "filename (Chg)" -> "filename"
3680
+ bracket_match = re.search(r'^(.+?)\s*\([^)]+\)\s*$', label)
3681
+ if bracket_match:
3682
+ potential_base = bracket_match.group(1).strip()
3683
+ if potential_base:
3684
+ base_name = potential_base
3685
+ break
3686
+ else:
3687
+ # Try to extract from text suffix patterns
3688
+ for suffix in [' charge', ' discharge', ' efficiency']:
3689
+ if label.endswith(suffix):
3690
+ potential_base = label[:-len(suffix)].strip()
3691
+ if potential_base:
3692
+ base_name = potential_base
3693
+ break
3694
+ if base_name != current_file.get('filename', 'Data'):
3695
+ break
3696
+
3697
+ print(f"Current file name in legend: '{base_name}'")
3698
+ new_name = input("Enter new file name (q=cancel): ").strip()
3699
+ if new_name and new_name.lower() != 'q':
3700
+ try:
3701
+ push_state("rename-legend")
3702
+
3703
+ # Extract bracket content from original labels if present
3704
+ import re
3705
+ chg_bracket = ''
3706
+ dchg_bracket = ''
3707
+ eff_bracket = ''
3708
+
3709
+ # Check for bracket patterns in original labels
3710
+ chg_match = re.search(r'\(([^)]+)\)', chg_label)
3711
+ if chg_match:
3712
+ chg_bracket = chg_match.group(1)
3713
+ dchg_match = re.search(r'\(([^)]+)\)', dchg_label)
3714
+ if dchg_match:
3715
+ dchg_bracket = dchg_match.group(1)
3716
+ # Fix capitalization: Dchg -> DChg
3717
+ if dchg_bracket.lower() == 'dchg':
3718
+ dchg_bracket = 'DChg'
3719
+ eff_match = re.search(r'\(([^)]+)\)', eff_label)
3720
+ if eff_match:
3721
+ eff_bracket = eff_match.group(1)
3722
+
3723
+ # If no brackets found, extract from label suffix or use defaults
3724
+ if not chg_bracket:
3725
+ # Try to extract from " charge" suffix
3726
+ if chg_label.endswith(' charge'):
3727
+ chg_bracket = 'Chg'
3728
+ else:
3729
+ chg_bracket = 'Chg'
3730
+ if not dchg_bracket:
3731
+ # Try to extract from " discharge" suffix
3732
+ if dchg_label.endswith(' discharge'):
3733
+ dchg_bracket = 'DChg'
3734
+ else:
3735
+ dchg_bracket = 'DChg'
3736
+ if not eff_bracket:
3737
+ # Try to extract from " efficiency" suffix
3738
+ if eff_label.endswith(' efficiency'):
3739
+ eff_bracket = 'Eff'
3740
+ else:
3741
+ eff_bracket = 'Eff'
3742
+
3743
+ # Build new labels with brackets preserved
3744
+ new_chg_label = f"{new_name} ({chg_bracket})"
3745
+ new_dchg_label = f"{new_name} ({dchg_bracket})"
3746
+ new_eff_label = f"{new_name} ({eff_bracket})"
3747
+
3748
+ # Update labels
3749
+ sc_chg.set_label(new_chg_label)
3750
+ sc_dchg.set_label(new_dchg_label)
3751
+ sc_eff.set_label(new_eff_label)
3752
+
3753
+ # Update filename in file_data
3754
+ current_file['filename'] = new_name
3755
+
3756
+ # Rebuild legend (preserve position)
3757
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
3758
+ fig.canvas.draw_idle()
3759
+ print(f"Legend labels updated: '{new_chg_label}', '{new_dchg_label}', '{new_eff_label}'")
3760
+ except Exception as e:
3761
+ print(f"Error: {e}")
3762
+ else:
3763
+ print("Invalid file number.")
3764
+ except (ValueError, KeyboardInterrupt):
3765
+ print("Invalid input.")
3766
+ elif sub == 'x':
3154
3767
  current = ax.get_xlabel()
3155
3768
  print(f"Current x-axis title: '{current}'")
3156
3769
  new_title = input("Enter new x-axis title (q=cancel): ")
@@ -3310,202 +3923,202 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3310
3923
  if ycmd == 'q':
3311
3924
  break
3312
3925
  if ycmd == 'ly':
3313
- current_ylim = ax.get_ylim()
3314
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3315
- rng = input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3316
- if not rng or rng.lower() == 'q':
3317
- continue
3318
- if rng.lower() == 'w':
3319
- # Upper only: change upper limit, fix lower - stay in loop
3320
- while True:
3321
- current_ylim = ax.get_ylim()
3322
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3323
- val = input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3324
- if not val or val.lower() == 'q':
3325
- break
3326
- try:
3327
- new_upper = float(val)
3328
- except (ValueError, KeyboardInterrupt):
3329
- print("Invalid value, ignored.")
3330
- continue
3331
- push_state("y-left-range")
3332
- ax.set_ylim(current_ylim[0], new_upper)
3333
- ax.relim()
3334
- ax.autoscale_view(scalex=False, scaley=True)
3335
- # Reapply legend position after axis change to prevent movement
3336
- try:
3337
- leg = ax.get_legend()
3338
- if leg is not None and leg.get_visible():
3339
- _apply_legend_position()
3340
- except Exception:
3341
- pass
3342
- fig.canvas.draw_idle()
3343
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3344
- if rng.lower() == 'w':
3345
- continue
3346
- if rng.lower() == 's':
3347
- # Lower only: change lower limit, fix upper - stay in loop
3348
- while True:
3349
- current_ylim = ax.get_ylim()
3350
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3351
- val = input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3352
- if not val or val.lower() == 'q':
3353
- break
3354
- try:
3355
- new_lower = float(val)
3356
- except (ValueError, KeyboardInterrupt):
3357
- print("Invalid value, ignored.")
3358
- continue
3359
- push_state("y-left-range")
3360
- ax.set_ylim(new_lower, current_ylim[1])
3361
- ax.relim()
3362
- ax.autoscale_view(scalex=False, scaley=True)
3363
- # Reapply legend position after axis change to prevent movement
3364
- try:
3365
- leg = ax.get_legend()
3366
- if leg is not None and leg.get_visible():
3367
- _apply_legend_position()
3368
- except Exception:
3369
- pass
3370
- fig.canvas.draw_idle()
3371
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3372
- continue
3373
- if rng.lower() == 'a':
3374
- # Auto: restore original range from scatter plots
3375
- push_state("y-left-range-auto")
3376
- try:
3377
- all_y = []
3378
- for sc in [sc_charge, sc_discharge]:
3379
- if sc is not None and hasattr(sc, 'get_offsets'):
3380
- offsets = sc.get_offsets()
3381
- if offsets.size > 0:
3382
- all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
3383
- if all_y:
3384
- orig_min = min(all_y)
3385
- orig_max = max(all_y)
3386
- ax.set_ylim(orig_min, orig_max)
3926
+ while True:
3927
+ current_ylim = ax.get_ylim()
3928
+ 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()
3930
+ if not rng or rng.lower() == 'q':
3931
+ break
3932
+ if rng.lower() == 'w':
3933
+ # Upper only: change upper limit, fix lower - stay in loop
3934
+ while True:
3935
+ current_ylim = ax.get_ylim()
3936
+ 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()
3938
+ if not val or val.lower() == 'q':
3939
+ break
3940
+ try:
3941
+ new_upper = float(val)
3942
+ except (ValueError, KeyboardInterrupt):
3943
+ print("Invalid value, ignored.")
3944
+ continue
3945
+ push_state("y-left-range")
3946
+ ax.set_ylim(current_ylim[0], new_upper)
3387
3947
  ax.relim()
3388
3948
  ax.autoscale_view(scalex=False, scaley=True)
3949
+ # Reapply legend position after axis change to prevent movement
3950
+ try:
3951
+ leg = ax.get_legend()
3952
+ if leg is not None and leg.get_visible():
3953
+ _apply_legend_position()
3954
+ except Exception:
3955
+ pass
3389
3956
  fig.canvas.draw_idle()
3390
- print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3391
- else:
3392
- print("No original data available.")
3393
- except Exception as e:
3394
- print(f"Error restoring original left Y range: {e}")
3395
- continue
3396
- parts = rng.replace(',', ' ').split()
3397
- if len(parts) != 2:
3398
- print("Need two numbers."); continue
3399
- try:
3400
- lo = float(parts[0]); hi = float(parts[1])
3401
- if lo == hi:
3402
- print("Min and max cannot be equal."); continue
3403
- push_state("y-left-range")
3404
- ax.set_ylim(min(lo, hi), max(lo, hi))
3405
- fig.canvas.draw_idle()
3406
- except Exception:
3407
- print("Invalid numbers.")
3408
- elif ycmd == 'ry':
3409
- try:
3410
- eff_on = bool(sc_eff.get_visible())
3411
- except Exception:
3412
- eff_on = True
3413
- if not eff_on:
3414
- print("Right Y is not shown; enable efficiency with 'ry' first.")
3415
- continue
3416
- current_ylim = ax2.get_ylim()
3417
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3418
- rng = input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3419
- if not rng or rng.lower() == 'q':
3420
- continue
3421
- if rng.lower() == 'w':
3422
- # Upper only: change upper limit, fix lower - stay in loop
3423
- while True:
3424
- current_ylim = ax2.get_ylim()
3425
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3426
- val = input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3427
- if not val or val.lower() == 'q':
3428
- break
3429
- try:
3430
- new_upper = float(val)
3431
- except (ValueError, KeyboardInterrupt):
3432
- print("Invalid value, ignored.")
3433
- continue
3434
- push_state("y-right-range")
3435
- ax2.set_ylim(current_ylim[0], new_upper)
3436
- ax2.relim()
3437
- ax2.autoscale_view(scalex=False, scaley=True)
3438
- # Reapply legend position after axis change to prevent movement
3439
- try:
3440
- leg = ax.get_legend()
3441
- if leg is not None and leg.get_visible():
3442
- _apply_legend_position()
3443
- except Exception:
3444
- pass
3445
- fig.canvas.draw_idle()
3446
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3447
- if rng.lower() == 'w':
3448
- continue
3449
- if rng.lower() == 's':
3450
- # Lower only: change lower limit, fix upper - stay in loop
3451
- while True:
3452
- current_ylim = ax2.get_ylim()
3453
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3454
- val = input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3455
- if not val or val.lower() == 'q':
3456
- break
3457
- try:
3458
- new_lower = float(val)
3459
- except (ValueError, KeyboardInterrupt):
3460
- print("Invalid value, ignored.")
3461
- continue
3462
- push_state("y-right-range")
3463
- ax2.set_ylim(new_lower, current_ylim[1])
3464
- ax2.relim()
3465
- ax2.autoscale_view(scalex=False, scaley=True)
3466
- # Reapply legend position after axis change to prevent movement
3957
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3958
+ continue
3959
+ if rng.lower() == 's':
3960
+ # Lower only: change lower limit, fix upper - stay in loop
3961
+ while True:
3962
+ current_ylim = ax.get_ylim()
3963
+ 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()
3965
+ if not val or val.lower() == 'q':
3966
+ break
3967
+ try:
3968
+ new_lower = float(val)
3969
+ except (ValueError, KeyboardInterrupt):
3970
+ print("Invalid value, ignored.")
3971
+ continue
3972
+ push_state("y-left-range")
3973
+ ax.set_ylim(new_lower, current_ylim[1])
3974
+ ax.relim()
3975
+ ax.autoscale_view(scalex=False, scaley=True)
3976
+ # Reapply legend position after axis change to prevent movement
3977
+ try:
3978
+ leg = ax.get_legend()
3979
+ if leg is not None and leg.get_visible():
3980
+ _apply_legend_position()
3981
+ except Exception:
3982
+ pass
3983
+ fig.canvas.draw_idle()
3984
+ print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3985
+ continue
3986
+ if rng.lower() == 'a':
3987
+ # Auto: restore original range from scatter plots
3988
+ push_state("y-left-range-auto")
3467
3989
  try:
3468
- leg = ax.get_legend()
3469
- if leg is not None and leg.get_visible():
3470
- _apply_legend_position()
3471
- except Exception:
3472
- pass
3990
+ all_y = []
3991
+ for sc in [sc_charge, sc_discharge]:
3992
+ if sc is not None and hasattr(sc, 'get_offsets'):
3993
+ offsets = sc.get_offsets()
3994
+ if offsets.size > 0:
3995
+ all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
3996
+ if all_y:
3997
+ orig_min = min(all_y)
3998
+ orig_max = max(all_y)
3999
+ ax.set_ylim(orig_min, orig_max)
4000
+ ax.relim()
4001
+ ax.autoscale_view(scalex=False, scaley=True)
4002
+ fig.canvas.draw_idle()
4003
+ print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
4004
+ else:
4005
+ print("No original data available.")
4006
+ except Exception as e:
4007
+ print(f"Error restoring original left Y range: {e}")
4008
+ continue
4009
+ parts = rng.replace(',', ' ').split()
4010
+ if len(parts) != 2:
4011
+ print("Need two numbers."); continue
4012
+ try:
4013
+ lo = float(parts[0]); hi = float(parts[1])
4014
+ if lo == hi:
4015
+ print("Min and max cannot be equal."); continue
4016
+ push_state("y-left-range")
4017
+ ax.set_ylim(min(lo, hi), max(lo, hi))
3473
4018
  fig.canvas.draw_idle()
3474
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3475
- continue
3476
- if rng.lower() == 'a':
3477
- # Auto: restore original range from efficiency scatter plot
3478
- push_state("y-right-range-auto")
4019
+ except Exception:
4020
+ print("Invalid numbers.")
4021
+ elif ycmd == 'ry':
4022
+ while True:
3479
4023
  try:
3480
- if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
3481
- offsets = sc_eff.get_offsets()
3482
- if offsets.size > 0:
3483
- orig_min = float(offsets[:, 1].min())
3484
- orig_max = float(offsets[:, 1].max())
3485
- ax2.set_ylim(orig_min, orig_max)
3486
- ax2.relim()
3487
- ax2.autoscale_view(scalex=False, scaley=True)
3488
- fig.canvas.draw_idle()
3489
- print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4024
+ eff_on = bool(sc_eff.get_visible())
4025
+ except Exception:
4026
+ eff_on = True
4027
+ if not eff_on:
4028
+ print("Right Y is not shown; enable efficiency with 'ry' first.")
4029
+ break
4030
+ current_ylim = ax2.get_ylim()
4031
+ 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()
4033
+ if not rng or rng.lower() == 'q':
4034
+ break
4035
+ if rng.lower() == 'w':
4036
+ # Upper only: change upper limit, fix lower - stay in loop
4037
+ while True:
4038
+ current_ylim = ax2.get_ylim()
4039
+ 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()
4041
+ if not val or val.lower() == 'q':
4042
+ break
4043
+ try:
4044
+ new_upper = float(val)
4045
+ except (ValueError, KeyboardInterrupt):
4046
+ print("Invalid value, ignored.")
4047
+ continue
4048
+ push_state("y-right-range")
4049
+ ax2.set_ylim(current_ylim[0], new_upper)
4050
+ ax2.relim()
4051
+ ax2.autoscale_view(scalex=False, scaley=True)
4052
+ # Reapply legend position after axis change to prevent movement
4053
+ try:
4054
+ leg = ax.get_legend()
4055
+ if leg is not None and leg.get_visible():
4056
+ _apply_legend_position()
4057
+ except Exception:
4058
+ pass
4059
+ fig.canvas.draw_idle()
4060
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4061
+ continue
4062
+ if rng.lower() == 's':
4063
+ # Lower only: change lower limit, fix upper - stay in loop
4064
+ while True:
4065
+ current_ylim = ax2.get_ylim()
4066
+ 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()
4068
+ if not val or val.lower() == 'q':
4069
+ break
4070
+ try:
4071
+ new_lower = float(val)
4072
+ except (ValueError, KeyboardInterrupt):
4073
+ print("Invalid value, ignored.")
4074
+ continue
4075
+ push_state("y-right-range")
4076
+ ax2.set_ylim(new_lower, current_ylim[1])
4077
+ ax2.relim()
4078
+ ax2.autoscale_view(scalex=False, scaley=True)
4079
+ # Reapply legend position after axis change to prevent movement
4080
+ try:
4081
+ leg = ax.get_legend()
4082
+ if leg is not None and leg.get_visible():
4083
+ _apply_legend_position()
4084
+ except Exception:
4085
+ pass
4086
+ fig.canvas.draw_idle()
4087
+ print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4088
+ continue
4089
+ if rng.lower() == 'a':
4090
+ # Auto: restore original range from efficiency scatter plot
4091
+ push_state("y-right-range-auto")
4092
+ try:
4093
+ if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
4094
+ offsets = sc_eff.get_offsets()
4095
+ if offsets.size > 0:
4096
+ orig_min = float(offsets[:, 1].min())
4097
+ orig_max = float(offsets[:, 1].max())
4098
+ ax2.set_ylim(orig_min, orig_max)
4099
+ ax2.relim()
4100
+ ax2.autoscale_view(scalex=False, scaley=True)
4101
+ fig.canvas.draw_idle()
4102
+ print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
4103
+ else:
4104
+ print("No original data available.")
3490
4105
  else:
3491
4106
  print("No original data available.")
3492
- else:
3493
- print("No original data available.")
3494
- except Exception as e:
3495
- print(f"Error restoring original right Y range: {e}")
3496
- continue
3497
- parts = rng.replace(',', ' ').split()
3498
- if len(parts) != 2:
3499
- print("Need two numbers."); continue
3500
- try:
3501
- lo = float(parts[0]); hi = float(parts[1])
3502
- if lo == hi:
3503
- print("Min and max cannot be equal."); continue
3504
- push_state("y-right-range")
3505
- ax2.set_ylim(min(lo, hi), max(lo, hi))
3506
- fig.canvas.draw_idle()
3507
- except Exception:
3508
- print("Invalid numbers.")
4107
+ except Exception as e:
4108
+ print(f"Error restoring original right Y range: {e}")
4109
+ continue
4110
+ parts = rng.replace(',', ' ').split()
4111
+ if len(parts) != 2:
4112
+ print("Need two numbers."); continue
4113
+ try:
4114
+ lo = float(parts[0]); hi = float(parts[1])
4115
+ if lo == hi:
4116
+ print("Min and max cannot be equal."); continue
4117
+ push_state("y-right-range")
4118
+ ax2.set_ylim(min(lo, hi), max(lo, hi))
4119
+ fig.canvas.draw_idle()
4120
+ except Exception:
4121
+ print("Invalid numbers.")
3509
4122
  _print_menu(); continue
3510
4123
  else:
3511
4124
  print("Unknown key.")