batplot 1.7.24__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:
@@ -99,8 +120,8 @@ def _color_of(artist):
99
120
  return None
100
121
 
101
122
 
102
- def _get_legend_title(fig, default: str = "Legend") -> str:
103
- """Fetch stored legend title, falling back to current legend text or default."""
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."""
104
125
  try:
105
126
  title = getattr(fig, '_cpc_legend_title', None)
106
127
  if isinstance(title, str) and title:
@@ -215,7 +236,7 @@ def _print_menu():
215
236
  " v: show/hide files",
216
237
  ]
217
238
  col2 = [
218
- "r: rename titles",
239
+ "r: rename",
219
240
  "x: x range",
220
241
  "y: y ranges",
221
242
  ]
@@ -262,9 +283,22 @@ def _print_file_list(file_data, current_idx):
262
283
  print()
263
284
 
264
285
 
265
- def _rebuild_legend(ax, ax2, file_data):
266
- """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
+ """
267
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
+
268
302
  h1, l1 = ax.get_legend_handles_labels()
269
303
  h2, l2 = ax2.get_legend_handles_labels()
270
304
  # Filter to only visible items
@@ -273,8 +307,22 @@ def _rebuild_legend(ax, ax2, file_data):
273
307
  if h.get_visible():
274
308
  h_all.append(h)
275
309
  l_all.append(l)
310
+
276
311
  if h_all:
277
- _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=_get_legend_title(ax.figure))
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)
278
326
  else:
279
327
  leg = ax.get_legend()
280
328
  if leg:
@@ -338,24 +386,35 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
338
386
  return None
339
387
  return None
340
388
 
341
- # 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)
342
396
  tick_vis = {
343
- 'bx': True,
344
- 'tx': False,
345
- 'ly': True,
346
- 'ry': True,
347
- 'mbx': False,
348
- 'mtx': False,
349
- 'mly': False,
350
- 'mry': False,
397
+ 'bx': True, 'tx': False, 'ly': True, 'ry': True,
398
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
351
399
  }
352
400
  try:
353
- # Infer from current axes state
354
- import matplotlib as _mpl
355
- tick_vis['bx'] = any(lbl.get_visible() for lbl in ax.get_xticklabels())
356
- tick_vis['tx'] = False # CPC doesn't duplicate top labels by default
357
- tick_vis['ly'] = any(lbl.get_visible() for lbl in ax.get_yticklabels())
358
- tick_vis['ry'] = any(lbl.get_visible() for lbl in ax2.get_yticklabels())
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())
359
418
  except Exception:
360
419
  pass
361
420
 
@@ -373,36 +432,38 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
373
432
  except Exception:
374
433
  return False
375
434
 
376
- wasd_state = {
377
- 'bottom': {
378
- 'spine': _get_spine_visible(ax, 'bottom'),
379
- 'ticks': bool(tick_vis.get('bx', True)),
380
- 'minor': bool(tick_vis.get('mbx', False)),
381
- 'labels': bool(tick_vis.get('bx', True)), # bottom x labels
382
- 'title': bool(ax.get_xlabel()) # bottom x title
383
- },
384
- 'top': {
385
- 'spine': _get_spine_visible(ax, 'top'),
386
- 'ticks': bool(tick_vis.get('tx', False)),
387
- 'minor': bool(tick_vis.get('mtx', False)),
388
- 'labels': bool(tick_vis.get('tx', False)),
389
- 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax._top_xlabel_text, 'get_visible', lambda: False)())
390
- },
391
- 'left': {
392
- 'spine': _get_spine_visible(ax, 'left'),
393
- 'ticks': bool(tick_vis.get('ly', True)),
394
- 'minor': bool(tick_vis.get('mly', False)),
395
- 'labels': bool(tick_vis.get('ly', True)), # left y labels (capacity)
396
- 'title': bool(ax.get_ylabel()) # left y title
397
- },
398
- 'right': {
399
- 'spine': _get_spine_visible(ax2, 'right'),
400
- 'ticks': bool(tick_vis.get('ry', True)),
401
- 'minor': bool(tick_vis.get('mry', False)),
402
- 'labels': bool(tick_vis.get('ry', True)), # right y labels (efficiency)
403
- 'title': bool(ax2.get_ylabel()) # right y title
404
- },
405
- }
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
+ }
406
467
 
407
468
  # Capture legend state
408
469
  legend_visible = False
@@ -515,8 +576,29 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
515
576
  'discharge_color': _color_of(f.get('sc_discharge')),
516
577
  'efficiency_color': _color_of(f.get('sc_eff')),
517
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
518
592
  multi_files.append(file_info)
519
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
520
602
 
521
603
  return cfg
522
604
 
@@ -728,6 +810,11 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
728
810
  tk = cfg.get('ticks', {})
729
811
  # Try wasd_state first (version 2), fall back to visibility dict (version 1)
730
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
731
818
  if wasd:
732
819
  # Use WASD state (20 parameters)
733
820
  bx = bool(wasd.get('bottom', {}).get('labels', True))
@@ -754,6 +841,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
754
841
  ax.tick_params(axis='x', bottom=bx, labelbottom=bx, top=tx, labeltop=tx)
755
842
  ax.tick_params(axis='y', left=ly, labelleft=ly)
756
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
757
850
  # Minor ticks
758
851
  from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator, NullLocator
759
852
  if mbx or mtx:
@@ -949,6 +1042,58 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
949
1042
  _ui_position_right_ylabel(ax2, fig, tick_state)
950
1043
  except Exception:
951
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
952
1097
  try:
953
1098
  fig.canvas.draw_idle()
954
1099
  except Exception:
@@ -1105,6 +1250,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1105
1250
  'mly': False, # minor left y-axis ticks - hidden by default
1106
1251
  'mry': False, # minor right y-axis ticks - hidden by default
1107
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
1108
1266
 
1109
1267
  # --- Undo stack using style snapshots ---
1110
1268
  state_history = [] # list of cfg dicts
@@ -1181,6 +1339,8 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1181
1339
  try:
1182
1340
  # Apply shared visibility to primary ax; then adjust twin for right side
1183
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)
1184
1344
  # Right axis tick params follow r_* keys
1185
1345
  ax2.tick_params(axis='y',
1186
1346
  right=tick_state.get('r_ticks', tick_state.get('ry', False)),
@@ -1239,7 +1399,6 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1239
1399
  """Reapply legend position using stored inches offset relative to canvas center."""
1240
1400
  try:
1241
1401
  xy_in = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
1242
- leg = ax.get_legend()
1243
1402
  if xy_in is None:
1244
1403
  return
1245
1404
  # Compute figure-fraction anchor from inches
@@ -1248,14 +1407,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1248
1407
  return
1249
1408
  fx = 0.5 + float(xy_in[0]) / float(fw)
1250
1409
  fy = 0.5 + float(xy_in[1]) / float(fh)
1251
- # Use current handles/labels
1252
- h1, l1 = ax.get_legend_handles_labels()
1253
- h2, l2 = ax2.get_legend_handles_labels()
1254
- if h1 or h2:
1410
+ # Use current visible handles/labels
1411
+ H, L = _visible_handles_labels(ax, ax2)
1412
+ if H:
1255
1413
  _legend_no_frame(
1256
1414
  ax,
1257
- h1 + h2,
1258
- l1 + l2,
1415
+ H,
1416
+ L,
1259
1417
  loc='center',
1260
1418
  bbox_to_anchor=(fx, fy),
1261
1419
  bbox_transform=fig.transFigure,
@@ -1279,9 +1437,6 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1279
1437
  pass
1280
1438
 
1281
1439
  _print_menu()
1282
- if is_multi_file:
1283
- print(f"\n[Multi-file mode: {len(file_data)} files loaded]")
1284
- _print_file_list(file_data, current_file_idx)
1285
1440
 
1286
1441
  while True:
1287
1442
  try:
@@ -1800,7 +1955,50 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1800
1955
  pass
1801
1956
 
1802
1957
  # Export the figure
1803
- 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')
1804
2002
  print(f"Exported figure to {target}")
1805
2003
  fig._last_figure_export_path = target
1806
2004
 
@@ -1819,6 +2017,58 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1819
2017
  # Save CPC session (.pkl) with all data and styles
1820
2018
  try:
1821
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
1822
2072
  folder = choose_save_path(file_paths, purpose="CPC session save")
1823
2073
  if not folder:
1824
2074
  _print_menu(); continue
@@ -2222,11 +2472,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2222
2472
  # Toggle efficiency visibility on the right axis
2223
2473
  try:
2224
2474
  push_state("toggle-eff")
2225
- vis = bool(sc_eff.get_visible()) if hasattr(sc_eff, 'get_visible') else True
2226
- new_vis = not vis
2227
2475
 
2228
- # 1. Hide/show efficiency points
2229
- 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)
2230
2497
 
2231
2498
  # 2. Hide/show right y-axis title
2232
2499
  try:
@@ -2242,68 +2509,42 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2242
2509
  except Exception:
2243
2510
  pass
2244
2511
 
2245
- # 4. Rebuild legend to remove/add efficiency entry
2512
+ # Persist WASD state so save/load and styles honor the toggle
2246
2513
  try:
2247
- h1, l1 = ax.get_legend_handles_labels()
2248
- except Exception:
2249
- h1, l1 = [], []
2250
- try:
2251
- h2, l2 = ax2.get_legend_handles_labels()
2252
- except Exception:
2253
- h2, l2 = [], []
2254
-
2255
- # Filter out efficiency entry if hidden
2256
- eff_label = None
2257
- try:
2258
- eff_label = sc_eff.get_label()
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)
2259
2543
  except Exception:
2260
2544
  pass
2261
2545
 
2262
- pairs1 = list(zip(h1, l1))
2263
- pairs2 = list(zip(h2, l2))
2264
-
2265
- def _keep(pair):
2266
- h, l = pair
2267
- # Drop invisible handles
2268
- try:
2269
- if hasattr(h, 'get_visible') and not h.get_visible():
2270
- return False
2271
- except Exception:
2272
- pass
2273
- # Drop the efficiency label when hidden
2274
- if eff_label and (l == eff_label) and not new_vis:
2275
- return False
2276
- return True
2277
-
2278
- vis_pairs1 = [p for p in pairs1 if _keep(p)]
2279
- vis_pairs2 = [p for p in pairs2 if _keep(p)]
2280
- H = [h for h, _ in vis_pairs1 + vis_pairs2]
2281
- L = [l for _, l in vis_pairs1 + vis_pairs2]
2282
-
2283
- if H:
2284
- try:
2285
- # Honor stored inch-based anchor if present; else fallback to 'best'
2286
- xy_in = getattr(fig, '_cpc_legend_xy_in', None)
2287
- if xy_in is not None:
2288
- try:
2289
- fw, fh = fig.get_size_inches()
2290
- fx = 0.5 + float(xy_in[0]) / float(fw)
2291
- fy = 0.5 + float(xy_in[1]) / float(fh)
2292
- _legend_no_frame(ax, H, L, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
2293
- except Exception:
2294
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2295
- else:
2296
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2297
- except Exception:
2298
- pass
2299
- else:
2300
- # No visible series: hide legend if present
2301
- try:
2302
- leg = ax.get_legend()
2303
- if leg is not None:
2304
- leg.set_visible(False)
2305
- except Exception:
2306
- pass
2546
+ # 4. Rebuild legend to remove/add efficiency entries (preserve position)
2547
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
2307
2548
 
2308
2549
  fig.canvas.draw_idle()
2309
2550
  except Exception:
@@ -2359,15 +2600,14 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2359
2600
  leg.set_visible(False)
2360
2601
  else:
2361
2602
  # Ensure a legend exists at the stored position
2362
- h1, l1 = ax.get_legend_handles_labels()
2363
- h2, l2 = ax2.get_legend_handles_labels()
2364
- if h1 or h2:
2603
+ H, L = _visible_handles_labels(ax, ax2)
2604
+ if H:
2365
2605
  offset = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
2366
2606
  if offset is not None:
2367
2607
  fig._cpc_legend_xy_in = offset
2368
2608
  _apply_legend_position()
2369
2609
  else:
2370
- _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)
2371
2611
  fig.canvas.draw_idle()
2372
2612
  except Exception:
2373
2613
  pass
@@ -3299,15 +3539,231 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3299
3539
  # Rename axis titles
3300
3540
  print("Tip: Use LaTeX/mathtext for special characters:")
3301
3541
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
3302
- print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3542
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
3303
3543
  while True:
3304
- 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")
3305
3545
  sub = input("Rename> ").strip().lower()
3306
3546
  if not sub:
3307
3547
  continue
3308
3548
  if sub == 'q':
3309
3549
  break
3310
- 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':
3311
3767
  current = ax.get_xlabel()
3312
3768
  print(f"Current x-axis title: '{current}'")
3313
3769
  new_title = input("Enter new x-axis title (q=cancel): ")
@@ -3467,202 +3923,202 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3467
3923
  if ycmd == 'q':
3468
3924
  break
3469
3925
  if ycmd == 'ly':
3470
- current_ylim = ax.get_ylim()
3471
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3472
- rng = input("Enter left y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3473
- if not rng or rng.lower() == 'q':
3474
- continue
3475
- if rng.lower() == 'w':
3476
- # Upper only: change upper limit, fix lower - stay in loop
3477
- while True:
3478
- current_ylim = ax.get_ylim()
3479
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3480
- val = input(f"Enter new upper left Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3481
- if not val or val.lower() == 'q':
3482
- break
3483
- try:
3484
- new_upper = float(val)
3485
- except (ValueError, KeyboardInterrupt):
3486
- print("Invalid value, ignored.")
3487
- continue
3488
- push_state("y-left-range")
3489
- ax.set_ylim(current_ylim[0], new_upper)
3490
- ax.relim()
3491
- ax.autoscale_view(scalex=False, scaley=True)
3492
- # Reapply legend position after axis change to prevent movement
3493
- try:
3494
- leg = ax.get_legend()
3495
- if leg is not None and leg.get_visible():
3496
- _apply_legend_position()
3497
- except Exception:
3498
- pass
3499
- fig.canvas.draw_idle()
3500
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3501
- if rng.lower() == 'w':
3502
- continue
3503
- if rng.lower() == 's':
3504
- # Lower only: change lower limit, fix upper - stay in loop
3505
- while True:
3506
- current_ylim = ax.get_ylim()
3507
- print(f"Current left Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3508
- val = input(f"Enter new lower left Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3509
- if not val or val.lower() == 'q':
3510
- break
3511
- try:
3512
- new_lower = float(val)
3513
- except (ValueError, KeyboardInterrupt):
3514
- print("Invalid value, ignored.")
3515
- continue
3516
- push_state("y-left-range")
3517
- ax.set_ylim(new_lower, current_ylim[1])
3518
- ax.relim()
3519
- ax.autoscale_view(scalex=False, scaley=True)
3520
- # Reapply legend position after axis change to prevent movement
3521
- try:
3522
- leg = ax.get_legend()
3523
- if leg is not None and leg.get_visible():
3524
- _apply_legend_position()
3525
- except Exception:
3526
- pass
3527
- fig.canvas.draw_idle()
3528
- print(f"Left Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3529
- continue
3530
- if rng.lower() == 'a':
3531
- # Auto: restore original range from scatter plots
3532
- push_state("y-left-range-auto")
3533
- try:
3534
- all_y = []
3535
- for sc in [sc_charge, sc_discharge]:
3536
- if sc is not None and hasattr(sc, 'get_offsets'):
3537
- offsets = sc.get_offsets()
3538
- if offsets.size > 0:
3539
- all_y.extend([offsets[:, 1].min(), offsets[:, 1].max()])
3540
- if all_y:
3541
- orig_min = min(all_y)
3542
- orig_max = max(all_y)
3543
- ax.set_ylim(orig_min, orig_max)
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)
3544
3947
  ax.relim()
3545
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
3546
3956
  fig.canvas.draw_idle()
3547
- print(f"Left Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
3548
- else:
3549
- print("No original data available.")
3550
- except Exception as e:
3551
- print(f"Error restoring original left Y range: {e}")
3552
- continue
3553
- parts = rng.replace(',', ' ').split()
3554
- if len(parts) != 2:
3555
- print("Need two numbers."); continue
3556
- try:
3557
- lo = float(parts[0]); hi = float(parts[1])
3558
- if lo == hi:
3559
- print("Min and max cannot be equal."); continue
3560
- push_state("y-left-range")
3561
- ax.set_ylim(min(lo, hi), max(lo, hi))
3562
- fig.canvas.draw_idle()
3563
- except Exception:
3564
- print("Invalid numbers.")
3565
- elif ycmd == 'ry':
3566
- try:
3567
- eff_on = bool(sc_eff.get_visible())
3568
- except Exception:
3569
- eff_on = True
3570
- if not eff_on:
3571
- print("Right Y is not shown; enable efficiency with 'ry' first.")
3572
- continue
3573
- current_ylim = ax2.get_ylim()
3574
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3575
- rng = input("Enter right y-range: min max, w=upper only, s=lower only, a=auto (restore original), q=cancel: ").strip()
3576
- if not rng or rng.lower() == 'q':
3577
- continue
3578
- if rng.lower() == 'w':
3579
- # Upper only: change upper limit, fix lower - stay in loop
3580
- while True:
3581
- current_ylim = ax2.get_ylim()
3582
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3583
- val = input(f"Enter new upper right Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
3584
- if not val or val.lower() == 'q':
3585
- break
3586
- try:
3587
- new_upper = float(val)
3588
- except (ValueError, KeyboardInterrupt):
3589
- print("Invalid value, ignored.")
3590
- continue
3591
- push_state("y-right-range")
3592
- ax2.set_ylim(current_ylim[0], new_upper)
3593
- ax2.relim()
3594
- ax2.autoscale_view(scalex=False, scaley=True)
3595
- # Reapply legend position after axis change to prevent movement
3596
- try:
3597
- leg = ax.get_legend()
3598
- if leg is not None and leg.get_visible():
3599
- _apply_legend_position()
3600
- except Exception:
3601
- pass
3602
- fig.canvas.draw_idle()
3603
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3604
- if rng.lower() == 'w':
3605
- continue
3606
- if rng.lower() == 's':
3607
- # Lower only: change lower limit, fix upper - stay in loop
3608
- while True:
3609
- current_ylim = ax2.get_ylim()
3610
- print(f"Current right Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
3611
- val = input(f"Enter new lower right Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
3612
- if not val or val.lower() == 'q':
3613
- break
3614
- try:
3615
- new_lower = float(val)
3616
- except (ValueError, KeyboardInterrupt):
3617
- print("Invalid value, ignored.")
3618
- continue
3619
- push_state("y-right-range")
3620
- ax2.set_ylim(new_lower, current_ylim[1])
3621
- ax2.relim()
3622
- ax2.autoscale_view(scalex=False, scaley=True)
3623
- # Reapply legend position after axis change to prevent movement
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")
3624
3989
  try:
3625
- leg = ax.get_legend()
3626
- if leg is not None and leg.get_visible():
3627
- _apply_legend_position()
3628
- except Exception:
3629
- pass
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))
3630
4018
  fig.canvas.draw_idle()
3631
- print(f"Right Y range updated: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
3632
- continue
3633
- if rng.lower() == 'a':
3634
- # Auto: restore original range from efficiency scatter plot
3635
- push_state("y-right-range-auto")
4019
+ except Exception:
4020
+ print("Invalid numbers.")
4021
+ elif ycmd == 'ry':
4022
+ while True:
3636
4023
  try:
3637
- if sc_eff is not None and hasattr(sc_eff, 'get_offsets'):
3638
- offsets = sc_eff.get_offsets()
3639
- if offsets.size > 0:
3640
- orig_min = float(offsets[:, 1].min())
3641
- orig_max = float(offsets[:, 1].max())
3642
- ax2.set_ylim(orig_min, orig_max)
3643
- ax2.relim()
3644
- ax2.autoscale_view(scalex=False, scaley=True)
3645
- fig.canvas.draw_idle()
3646
- print(f"Right Y range restored to original: {ax2.get_ylim()[0]:.6g} to {ax2.get_ylim()[1]:.6g}")
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.")
3647
4105
  else:
3648
4106
  print("No original data available.")
3649
- else:
3650
- print("No original data available.")
3651
- except Exception as e:
3652
- print(f"Error restoring original right Y range: {e}")
3653
- continue
3654
- parts = rng.replace(',', ' ').split()
3655
- if len(parts) != 2:
3656
- print("Need two numbers."); continue
3657
- try:
3658
- lo = float(parts[0]); hi = float(parts[1])
3659
- if lo == hi:
3660
- print("Min and max cannot be equal."); continue
3661
- push_state("y-right-range")
3662
- ax2.set_ylim(min(lo, hi), max(lo, hi))
3663
- fig.canvas.draw_idle()
3664
- except Exception:
3665
- print("Invalid numbers.")
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.")
3666
4122
  _print_menu(); continue
3667
4123
  else:
3668
4124
  print("Unknown key.")