batplot 1.8.4__py3-none-any.whl → 1.8.11__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.
@@ -96,7 +96,7 @@ from .color_utils import resolve_color_token, color_block, palette_preview, mana
96
96
 
97
97
 
98
98
  def _legend_no_frame(ax, *args, **kwargs):
99
- # Compact legend defaults and labelcolor matching marker/line color
99
+ # Compact legend defaults
100
100
  kwargs.setdefault('frameon', False)
101
101
  kwargs.setdefault('handlelength', 1.0)
102
102
  kwargs.setdefault('handletextpad', 0.35)
@@ -104,8 +104,8 @@ def _legend_no_frame(ax, *args, **kwargs):
104
104
  kwargs.setdefault('borderaxespad', 0.5)
105
105
  kwargs.setdefault('borderpad', 0.3)
106
106
  kwargs.setdefault('columnspacing', 0.6)
107
- # Let matplotlib color legend text from line/marker colors
108
- kwargs.setdefault('labelcolor', 'linecolor')
107
+ # Don't use labelcolor='linecolor' by default as it causes issues
108
+ # with scatter plots that have facecolor='none' (hollow markers)
109
109
  leg = ax.legend(*args, **kwargs)
110
110
  if leg is not None:
111
111
  try:
@@ -267,7 +267,7 @@ def _generate_similar_color(base_color):
267
267
  return base_color
268
268
 
269
269
 
270
- def _print_menu():
270
+ def _print_menu(fig=None):
271
271
  col1 = [
272
272
  " f: font",
273
273
  " l: line",
@@ -284,6 +284,7 @@ def _print_menu():
284
284
  "r: rename",
285
285
  "x: x range",
286
286
  "y: y ranges",
287
+ "ie: invert efficiency",
287
288
  ]
288
289
  col3 = [
289
290
  "p: print(export) style/geom",
@@ -293,6 +294,30 @@ def _print_menu():
293
294
  "b: undo",
294
295
  "q: quit",
295
296
  ]
297
+
298
+ # Conditional shortcuts that depend on figure state
299
+ # 1) Hide multi-file-only commands (v) when we know we're in single-file mode
300
+ if fig is not None:
301
+ try:
302
+ is_multi = bool(getattr(fig, '_cpc_is_multi_file', False))
303
+ except Exception:
304
+ is_multi = True
305
+ if not is_multi:
306
+ # Remove "v: show/hide files" in single-file mode
307
+ col1 = [item for item in col1 if not item.strip().startswith("v:")]
308
+
309
+ # 2) Conditional overwrite shortcuts under (Options) if figure is available
310
+ if fig is not None:
311
+ last_session = getattr(fig, "_last_session_save_path", None)
312
+ last_style = getattr(fig, "_last_style_export_path", None)
313
+ last_figure = getattr(fig, "_last_figure_export_path", None)
314
+ if last_session:
315
+ col3.append("os: overwrite session")
316
+ if last_style:
317
+ col3.append("ops: overwrite style")
318
+ col3.append("opsg: overwrite style+geom")
319
+ if last_figure:
320
+ col3.append("oe: overwrite figure")
296
321
  w1 = max(18, *(len(s) for s in col1))
297
322
  w2 = max(18, *(len(s) for s in col2))
298
323
  w3 = max(12, *(len(s) for s in col3))
@@ -413,6 +438,21 @@ def _get_geometry_snapshot(ax, ax2) -> Dict:
413
438
  return geom
414
439
 
415
440
 
441
+ def _is_hollow_marker(artist) -> bool:
442
+ """Check if a scatter artist has hollow markers (facecolor='none' or transparent)."""
443
+ try:
444
+ if hasattr(artist, 'get_facecolors'):
445
+ face_arr = artist.get_facecolors()
446
+ if face_arr is not None and len(face_arr):
447
+ # Check if facecolor is fully transparent (alpha == 0)
448
+ fc = face_arr[0]
449
+ if len(fc) >= 4 and fc[3] == 0:
450
+ return True
451
+ except Exception:
452
+ pass
453
+ return False
454
+
455
+
416
456
  def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=None) -> Dict:
417
457
  try:
418
458
  fig_w, fig_h = map(float, fig.get_size_inches())
@@ -420,18 +460,37 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
420
460
  fig_w = fig_h = None
421
461
 
422
462
  def _color_of(artist) -> Optional[str]:
463
+ """Return a representative color for a scatter/artist.
464
+
465
+ For hollow markers (facecolor='none'), fall back to edgecolor so that
466
+ style snapshots still capture the intended color.
467
+ """
423
468
  try:
469
+ # Prefer explicit color if available
424
470
  if hasattr(artist, 'get_color'):
425
471
  c = artist.get_color()
426
- # scatter returns array sometimes; pick first
427
472
  if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
428
473
  return c[0]
429
474
  return c
475
+ # Fall back to facecolors / edgecolors for scatter
476
+ face_arr = None
477
+ edge_arr = None
430
478
  if hasattr(artist, 'get_facecolors'):
431
- arr = artist.get_facecolors()
432
- if arr is not None and len(arr):
433
- from matplotlib.colors import to_hex
434
- return to_hex(arr[0])
479
+ face_arr = artist.get_facecolors()
480
+ if hasattr(artist, 'get_edgecolors'):
481
+ edge_arr = artist.get_edgecolors()
482
+ from matplotlib.colors import to_hex
483
+ # If facecolor is 'none' or empty, use edgecolor instead
484
+ if face_arr is not None and len(face_arr):
485
+ # Some backends use fully transparent facecolor for 'none'
486
+ fc = face_arr[0]
487
+ try:
488
+ if fc[3] > 0:
489
+ return to_hex(fc)
490
+ except Exception:
491
+ return to_hex(fc)
492
+ if edge_arr is not None and len(edge_arr):
493
+ return to_hex(edge_arr[0])
435
494
  except Exception:
436
495
  pass
437
496
  return None
@@ -621,12 +680,14 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
621
680
  'marker': getattr(sc_charge, 'get_marker', lambda: 'o')(),
622
681
  'markersize': float(getattr(sc_charge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_charge, 'get_sizes') else 32.0,
623
682
  'alpha': float(sc_charge.get_alpha()) if sc_charge.get_alpha() is not None else 1.0,
683
+ 'hollow': _is_hollow_marker(sc_charge),
624
684
  },
625
685
  'discharge': {
626
686
  'color': _color_of(sc_discharge),
627
687
  'marker': getattr(sc_discharge, 'get_marker', lambda: 's')(),
628
688
  'markersize': float(getattr(sc_discharge, 'get_sizes', lambda: [32])()[0]) if hasattr(sc_discharge, 'get_sizes') else 32.0,
629
689
  'alpha': float(sc_discharge.get_alpha()) if sc_discharge.get_alpha() is not None else 1.0,
690
+ 'hollow': _is_hollow_marker(sc_discharge),
630
691
  },
631
692
  'efficiency': {
632
693
  'color': (sc_eff.get_facecolors()[0].tolist() if hasattr(sc_eff, 'get_facecolors') and len(sc_eff.get_facecolors()) else '#2ca02c'),
@@ -634,6 +695,7 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
634
695
  'markersize': float(getattr(sc_eff, 'get_sizes', lambda: [40])()[0]) if hasattr(sc_eff, 'get_sizes') else 40.0,
635
696
  'alpha': float(sc_eff.get_alpha()) if sc_eff.get_alpha() is not None else 1.0,
636
697
  'visible': bool(getattr(sc_eff, 'get_visible', lambda: True)()),
698
+ 'hollow': _is_hollow_marker(sc_eff),
637
699
  }
638
700
  }
639
701
  }
@@ -650,10 +712,13 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
650
712
  'visible': f.get('visible', True),
651
713
  'charge_color': _color_of(sc_chg),
652
714
  'charge_marker': getattr(sc_chg, 'get_marker', lambda: 'o')() if sc_chg else 'o',
715
+ 'charge_hollow': _is_hollow_marker(sc_chg) if sc_chg else False,
653
716
  'discharge_color': _color_of(sc_dchg),
654
717
  'discharge_marker': getattr(sc_dchg, 'get_marker', lambda: 's')() if sc_dchg else 's',
718
+ 'discharge_hollow': _is_hollow_marker(sc_dchg) if sc_dchg else False,
655
719
  'efficiency_color': _color_of(sc_eff),
656
720
  'efficiency_marker': getattr(sc_eff, 'get_marker', lambda: '^')() if sc_eff else '^',
721
+ 'efficiency_hollow': _is_hollow_marker(sc_eff) if sc_eff else False,
657
722
  }
658
723
  # Save legend labels
659
724
  try:
@@ -692,6 +757,11 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
692
757
  file_data: Optional list of file dicts for multi-file mode
693
758
  """
694
759
  is_multi_file = file_data is not None and len(file_data) > 1
760
+ # Store multi-file flag on figure so the menu can hide/show multi-file commands correctly
761
+ try:
762
+ fig._cpc_is_multi_file = bool(is_multi_file)
763
+ except Exception:
764
+ pass
695
765
 
696
766
  # Save current labelpad values BEFORE any style changes
697
767
  saved_xlabelpad = None
@@ -844,7 +914,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
844
914
  # Single file mode: apply to provided artists only
845
915
  if ch:
846
916
  if ch.get('color') is not None:
847
- sc_charge.set_color(ch['color'])
917
+ # Apply color respecting hollow marker style
918
+ if ch.get('hollow', False):
919
+ sc_charge.set_facecolors('none')
920
+ sc_charge.set_edgecolors(ch['color'])
921
+ else:
922
+ sc_charge.set_color(ch['color'])
848
923
  if ch.get('marker') is not None and hasattr(sc_charge, 'set_marker'):
849
924
  sc_charge.set_marker(ch['marker'])
850
925
  if ch.get('markersize') is not None and hasattr(sc_charge, 'set_sizes'):
@@ -853,7 +928,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
853
928
  sc_charge.set_alpha(float(ch['alpha']))
854
929
  if dh:
855
930
  if dh.get('color') is not None:
856
- sc_discharge.set_color(dh['color'])
931
+ # Apply color respecting hollow marker style
932
+ if dh.get('hollow', False):
933
+ sc_discharge.set_facecolors('none')
934
+ sc_discharge.set_edgecolors(dh['color'])
935
+ else:
936
+ sc_discharge.set_color(dh['color'])
857
937
  if dh.get('marker') is not None and hasattr(sc_discharge, 'set_marker'):
858
938
  sc_discharge.set_marker(dh['marker'])
859
939
  if dh.get('markersize') is not None and hasattr(sc_discharge, 'set_sizes'):
@@ -863,7 +943,12 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
863
943
  if ef:
864
944
  if ef.get('color') is not None:
865
945
  try:
866
- sc_eff.set_color(ef['color'])
946
+ # Apply color respecting hollow marker style
947
+ if ef.get('hollow', False):
948
+ sc_eff.set_facecolors('none')
949
+ sc_eff.set_edgecolors(ef['color'])
950
+ else:
951
+ sc_eff.set_color(ef['color'])
867
952
  except Exception:
868
953
  pass
869
954
  if ef.get('marker') is not None and hasattr(sc_eff, 'set_marker'):
@@ -878,6 +963,16 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
878
963
  ax2.yaxis.label.set_visible(bool(ef['visible']))
879
964
  except Exception:
880
965
  pass
966
+ # Restore legend labels for single-file mode
967
+ try:
968
+ if 'label' in ch and hasattr(sc_charge, 'set_label'):
969
+ sc_charge.set_label(ch['label'])
970
+ if 'label' in dh and hasattr(sc_discharge, 'set_label'):
971
+ sc_discharge.set_label(dh['label'])
972
+ if 'label' in ef and hasattr(sc_eff, 'set_label'):
973
+ sc_eff.set_label(ef['label'])
974
+ except Exception:
975
+ pass
881
976
  except Exception:
882
977
  pass
883
978
  # Apply legend state (h command)
@@ -1163,40 +1258,40 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
1163
1258
  for i, f_info in enumerate(multi_files):
1164
1259
  if i < len(file_data):
1165
1260
  f = file_data[i]
1166
- # Restore colors FIRST (before labels)
1261
+ # Restore colors FIRST (before labels), respecting hollow marker style
1167
1262
  if 'charge_color' in f_info and f.get('sc_charge'):
1168
1263
  try:
1169
1264
  col = f_info['charge_color']
1170
- f['sc_charge'].set_color(col)
1265
+ is_hollow = f_info.get('charge_hollow', False)
1266
+ if is_hollow:
1267
+ f['sc_charge'].set_facecolors('none')
1268
+ f['sc_charge'].set_edgecolors(col)
1269
+ else:
1270
+ f['sc_charge'].set_color(col)
1171
1271
  f['color'] = col
1172
- # Force update of facecolors for scatter plots
1173
- if hasattr(f['sc_charge'], 'set_facecolors'):
1174
- from matplotlib.colors import to_rgba
1175
- rgba = to_rgba(col)
1176
- f['sc_charge'].set_facecolors(rgba)
1177
1272
  except Exception:
1178
1273
  pass
1179
1274
  if 'discharge_color' in f_info and f.get('sc_discharge'):
1180
1275
  try:
1181
1276
  col = f_info['discharge_color']
1182
- f['sc_discharge'].set_color(col)
1183
- # Force update of facecolors for scatter plots
1184
- if hasattr(f['sc_discharge'], 'set_facecolors'):
1185
- from matplotlib.colors import to_rgba
1186
- rgba = to_rgba(col)
1187
- f['sc_discharge'].set_facecolors(rgba)
1277
+ is_hollow = f_info.get('discharge_hollow', False)
1278
+ if is_hollow:
1279
+ f['sc_discharge'].set_facecolors('none')
1280
+ f['sc_discharge'].set_edgecolors(col)
1281
+ else:
1282
+ f['sc_discharge'].set_color(col)
1188
1283
  except Exception:
1189
1284
  pass
1190
1285
  if 'efficiency_color' in f_info and f.get('sc_eff'):
1191
1286
  try:
1192
1287
  col = f_info['efficiency_color']
1193
- f['sc_eff'].set_color(col)
1288
+ is_hollow = f_info.get('efficiency_hollow', False)
1289
+ if is_hollow:
1290
+ f['sc_eff'].set_facecolors('none')
1291
+ f['sc_eff'].set_edgecolors(col)
1292
+ else:
1293
+ f['sc_eff'].set_color(col)
1194
1294
  f['eff_color'] = col
1195
- # Force update of facecolors for scatter plots
1196
- if hasattr(f['sc_eff'], 'set_facecolors'):
1197
- from matplotlib.colors import to_rgba
1198
- rgba = to_rgba(col)
1199
- f['sc_eff'].set_facecolors(rgba)
1200
1295
  except Exception:
1201
1296
  pass
1202
1297
  # Restore legend labels
@@ -1350,8 +1445,6 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1350
1445
  # If file_data is provided, we're in multi-file mode.
1351
1446
  # If not provided, we create a single-file structure for backward compatibility.
1352
1447
  # ====================================================================
1353
- is_multi_file = file_data is not None and len(file_data) > 1
1354
-
1355
1448
  if file_data is None:
1356
1449
  # Backward compatibility: create file_data structure from single file
1357
1450
  # This allows the function to work with old code that passes individual artists
@@ -1376,12 +1469,18 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1376
1469
  'sc_eff': sc_eff, # Efficiency scatter artist
1377
1470
  'visible': True # File is visible by default
1378
1471
  }]
1379
-
1380
1472
  # Track which file is currently selected for editing (in multi-file mode)
1381
1473
  current_file_idx = 0 # Index of currently selected file (0 = first file)
1382
1474
 
1383
1475
  # Collect file paths for session saving (if available)
1384
1476
  file_paths = _collect_file_paths(file_data)
1477
+
1478
+ # Multi-file flag: now that file_data is finalized
1479
+ is_multi_file = file_data is not None and len(file_data) > 1
1480
+ try:
1481
+ fig._cpc_is_multi_file = bool(is_multi_file)
1482
+ except Exception:
1483
+ pass
1385
1484
 
1386
1485
  # ====================================================================
1387
1486
  # TICK STATE MANAGEMENT
@@ -1619,7 +1718,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1619
1718
  except Exception:
1620
1719
  pass
1621
1720
 
1622
- _print_menu()
1721
+ _print_menu(fig)
1623
1722
 
1624
1723
  while True:
1625
1724
  try:
@@ -1640,7 +1739,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1640
1739
  _print_file_list(file_data, current_file_idx)
1641
1740
  choice = _safe_input(f"Toggle visibility for file (1-{len(file_data)}), 'a' for all, or q=cancel: ").strip()
1642
1741
  if choice.lower() == 'q':
1643
- _print_menu()
1742
+ _print_menu(fig)
1644
1743
  _print_file_list(file_data, current_file_idx)
1645
1744
  continue
1646
1745
 
@@ -1665,48 +1764,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1665
1764
  f['sc_eff'].set_visible(new_vis)
1666
1765
  else:
1667
1766
  print("Invalid file number.")
1767
+
1768
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1769
+ fig.canvas.draw_idle()
1668
1770
  else:
1669
- # Single file mode: toggle efficiency
1670
- push_state("visibility-eff")
1671
- # Capture current legend position BEFORE toggling visibility
1672
- try:
1673
- if not hasattr(fig, '_cpc_legend_xy_in') or getattr(fig, '_cpc_legend_xy_in') is None:
1674
- leg0 = ax.get_legend()
1675
- if leg0 is not None and leg0.get_visible():
1676
- try:
1677
- # Ensure renderer exists
1678
- try:
1679
- renderer = fig.canvas.get_renderer()
1680
- except Exception:
1681
- fig.canvas.draw()
1682
- renderer = fig.canvas.get_renderer()
1683
- bb = leg0.get_window_extent(renderer=renderer)
1684
- cx = 0.5 * (bb.x0 + bb.x1)
1685
- cy = 0.5 * (bb.y0 + bb.y1)
1686
- fx, fy = fig.transFigure.inverted().transform((cx, cy))
1687
- fw, fh = fig.get_size_inches()
1688
- offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
1689
- offset = _sanitize_legend_offset(offset)
1690
- if offset is not None:
1691
- fig._cpc_legend_xy_in = offset
1692
- except Exception:
1693
- pass
1694
- except Exception:
1695
- pass
1696
- vis = sc_eff.get_visible()
1697
- sc_eff.set_visible(not vis)
1698
- try:
1699
- ax2.yaxis.label.set_visible(not vis)
1700
- except Exception:
1701
- pass
1702
-
1703
- _rebuild_legend(ax, ax2, file_data, preserve_position=True)
1704
- fig.canvas.draw_idle()
1771
+ # Single file mode: v is not meaningful (no per-file visibility)
1772
+ print("File visibility (v) is only available in multi-file CPC mode.")
1705
1773
  except ValueError:
1706
1774
  print("Invalid input.")
1707
1775
  except Exception as e:
1708
1776
  print(f"Visibility toggle failed: {e}")
1709
- _print_menu()
1777
+ _print_menu(fig)
1710
1778
  if is_multi_file:
1711
1779
  _print_file_list(file_data, current_file_idx)
1712
1780
  continue
@@ -1719,10 +1787,10 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1719
1787
  if confirm == 'y':
1720
1788
  break
1721
1789
  else:
1722
- _print_menu(); continue
1790
+ _print_menu(fig); continue
1723
1791
  elif key == 'b':
1724
1792
  restore_state()
1725
- _print_menu(); continue
1793
+ _print_menu(fig); continue
1726
1794
  elif key == 'c':
1727
1795
  # Colors submenu: ly (left Y series) and ry (right Y efficiency), with user colors and palettes
1728
1796
  try:
@@ -1837,18 +1905,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1837
1905
  continue
1838
1906
  discharge_col = _generate_similar_color(charge_col)
1839
1907
  try:
1840
- f['sc_charge'].set_color(charge_col)
1841
- f['sc_discharge'].set_color(discharge_col)
1842
1908
  f['color'] = charge_col
1843
- # Force update of facecolors for scatter plots
1909
+ # Chg: filled square; DChg: unfilled (hollow) square
1844
1910
  if hasattr(f['sc_charge'], 'set_facecolors'):
1845
1911
  from matplotlib.colors import to_rgba
1846
- rgba = to_rgba(charge_col)
1847
- f['sc_charge'].set_facecolors(rgba)
1912
+ f['sc_charge'].set_color(charge_col)
1913
+ f['sc_charge'].set_facecolors(to_rgba(charge_col))
1914
+ else:
1915
+ f['sc_charge'].set_color(charge_col)
1848
1916
  if hasattr(f['sc_discharge'], 'set_facecolors'):
1849
- from matplotlib.colors import to_rgba
1850
- rgba = to_rgba(discharge_col)
1851
- f['sc_discharge'].set_facecolors(rgba)
1917
+ f['sc_discharge'].set_facecolors('none')
1918
+ f['sc_discharge'].set_edgecolors(discharge_col)
1919
+ else:
1920
+ f['sc_discharge'].set_color(discharge_col)
1852
1921
  except Exception as e:
1853
1922
  print(f"Error setting color: {e}")
1854
1923
  pass
@@ -1885,18 +1954,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1885
1954
  continue
1886
1955
  discharge_col = _generate_similar_color(charge_col)
1887
1956
  try:
1888
- file_data[file_idx]['sc_charge'].set_color(charge_col)
1889
- file_data[file_idx]['sc_discharge'].set_color(discharge_col)
1890
1957
  file_data[file_idx]['color'] = charge_col
1891
- # Force update of facecolors for scatter plots
1958
+ # Chg: filled square; DChg: unfilled (hollow) square
1892
1959
  if hasattr(file_data[file_idx]['sc_charge'], 'set_facecolors'):
1893
1960
  from matplotlib.colors import to_rgba
1894
- rgba = to_rgba(charge_col)
1895
- file_data[file_idx]['sc_charge'].set_facecolors(rgba)
1961
+ file_data[file_idx]['sc_charge'].set_color(charge_col)
1962
+ file_data[file_idx]['sc_charge'].set_facecolors(to_rgba(charge_col))
1963
+ else:
1964
+ file_data[file_idx]['sc_charge'].set_color(charge_col)
1896
1965
  if hasattr(file_data[file_idx]['sc_discharge'], 'set_facecolors'):
1897
- from matplotlib.colors import to_rgba
1898
- rgba = to_rgba(discharge_col)
1899
- file_data[file_idx]['sc_discharge'].set_facecolors(rgba)
1966
+ file_data[file_idx]['sc_discharge'].set_facecolors('none')
1967
+ file_data[file_idx]['sc_discharge'].set_edgecolors(discharge_col)
1968
+ else:
1969
+ file_data[file_idx]['sc_discharge'].set_color(discharge_col)
1900
1970
  except Exception:
1901
1971
  pass
1902
1972
  _apply_manual_entries(tokens)
@@ -2007,7 +2077,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2007
2077
  print("Unknown option.")
2008
2078
  except Exception as e:
2009
2079
  print(f"Error in colors menu: {e}")
2010
- _print_menu()
2080
+ _print_menu(fig)
2011
2081
  if is_multi_file:
2012
2082
  _print_file_list(file_data, current_file_idx)
2013
2083
  continue
@@ -2070,7 +2140,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2070
2140
  fig.canvas.draw()
2071
2141
  except Exception as e:
2072
2142
  print(f"Error in spine color menu: {e}")
2073
- _print_menu()
2143
+ _print_menu(fig)
2074
2144
  if is_multi_file:
2075
2145
  _print_file_list(file_data, current_file_idx)
2076
2146
  continue
@@ -2078,7 +2148,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2078
2148
  try:
2079
2149
  base_path = choose_save_path(file_paths, purpose="figure export")
2080
2150
  if not base_path:
2081
- _print_menu()
2151
+ _print_menu(fig)
2082
2152
  continue
2083
2153
  print(f"\nChosen path: {base_path}")
2084
2154
  # List existing figure files from Figures/ subdirectory
@@ -2101,19 +2171,19 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2101
2171
  else:
2102
2172
  fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
2103
2173
  if not fname or fname.lower() == 'q':
2104
- _print_menu(); continue
2174
+ _print_menu(fig); continue
2105
2175
 
2106
2176
  # Check for 'o' option
2107
2177
  if fname.lower() == 'o':
2108
2178
  if not last_figure_path:
2109
2179
  print("No previous export found.")
2110
- _print_menu(); continue
2180
+ _print_menu(fig); continue
2111
2181
  if not os.path.exists(last_figure_path):
2112
2182
  print(f"Previous export file not found: {last_figure_path}")
2113
- _print_menu(); continue
2183
+ _print_menu(fig); continue
2114
2184
  yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
2115
2185
  if yn != 'y':
2116
- _print_menu(); continue
2186
+ _print_menu(fig); continue
2117
2187
  target = last_figure_path
2118
2188
  # Check if user selected a number
2119
2189
  elif fname.isdigit() and files:
@@ -2126,7 +2196,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2126
2196
  target = file_list[idx-1][1] # Full path from list
2127
2197
  else:
2128
2198
  print("Invalid number.")
2129
- _print_menu(); continue
2199
+ _print_menu(fig); continue
2130
2200
  else:
2131
2201
  root, ext = os.path.splitext(fname)
2132
2202
  if ext == '':
@@ -2139,7 +2209,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2139
2209
  if os.path.exists(target):
2140
2210
  yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2141
2211
  if yn != 'y':
2142
- _print_menu(); continue
2212
+ _print_menu(fig); continue
2143
2213
  if target:
2144
2214
  # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
2145
2215
  from .utils import ensure_exact_case_filename
@@ -2258,7 +2328,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2258
2328
  pass
2259
2329
  except Exception as e:
2260
2330
  print(f"Export failed: {e}")
2261
- _print_menu(); continue
2331
+ _print_menu(fig); continue
2262
2332
  elif key == 's':
2263
2333
  # Save CPC session (.pkl) with all data and styles
2264
2334
  try:
@@ -2317,7 +2387,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2317
2387
  pass
2318
2388
  folder = choose_save_path(file_paths, purpose="CPC session save")
2319
2389
  if not folder:
2320
- _print_menu(); continue
2390
+ _print_menu(fig); continue
2321
2391
  print(f"\nChosen path: {folder}")
2322
2392
  try:
2323
2393
  files = sorted([f for f in os.listdir(folder) if f.lower().endswith('.pkl')])
@@ -2339,18 +2409,18 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2339
2409
  prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2340
2410
  choice = _safe_input(prompt).strip()
2341
2411
  if not choice or choice.lower() == 'q':
2342
- _print_menu(); continue
2412
+ _print_menu(fig); continue
2343
2413
  if choice.lower() == 'o':
2344
2414
  # Overwrite last saved session
2345
2415
  if not last_session_path:
2346
2416
  print("No previous save found.")
2347
- _print_menu(); continue
2417
+ _print_menu(fig); continue
2348
2418
  if not os.path.exists(last_session_path):
2349
2419
  print(f"Previous save file not found: {last_session_path}")
2350
- _print_menu(); continue
2420
+ _print_menu(fig); continue
2351
2421
  yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2352
2422
  if yn != 'y':
2353
- _print_menu(); continue
2423
+ _print_menu(fig); continue
2354
2424
  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)
2355
2425
  print(f"Overwritten session to {last_session_path}")
2356
2426
  _print_menu(); continue
@@ -2367,7 +2437,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2367
2437
  _print_menu(); continue
2368
2438
  else:
2369
2439
  print("Invalid number.")
2370
- _print_menu(); continue
2440
+ _print_menu(fig); continue
2371
2441
  if choice.lower() != 'o':
2372
2442
  name = choice
2373
2443
  root, ext = os.path.splitext(name)
@@ -2377,12 +2447,12 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2377
2447
  if os.path.exists(target):
2378
2448
  yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2379
2449
  if yn != 'y':
2380
- _print_menu(); continue
2450
+ _print_menu(fig); continue
2381
2451
  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)
2382
2452
  fig._last_session_save_path = target
2383
2453
  except Exception as e:
2384
2454
  print(f"Save failed: {e}")
2385
- _print_menu(); continue
2455
+ _print_menu(fig); continue
2386
2456
  elif key == 'p':
2387
2457
  try:
2388
2458
  style_menu_active = True
@@ -2670,12 +2740,12 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2670
2740
  print("Unknown choice.")
2671
2741
  except Exception as e:
2672
2742
  print(f"Error in style submenu: {e}")
2673
- _print_menu(); continue
2743
+ _print_menu(fig); continue
2674
2744
  elif key == 'i':
2675
2745
  try:
2676
2746
  path = choose_style_file(file_paths, purpose="style import")
2677
2747
  if not path:
2678
- _print_menu(); continue
2748
+ _print_menu(fig); continue
2679
2749
  push_state("import-style")
2680
2750
  with open(path, 'r', encoding='utf-8') as f:
2681
2751
  cfg = json.load(f)
@@ -2683,7 +2753,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2683
2753
  # Check file type
2684
2754
  kind = cfg.get('kind', '')
2685
2755
  if kind not in ('cpc_style', 'cpc_style_geom'):
2686
- print("Not a CPC style file."); _print_menu(); continue
2756
+ print("Not a CPC style file."); _print_menu(fig); continue
2687
2757
 
2688
2758
  # Enforce compatibility between style/geom ro state and current figure ro state
2689
2759
  file_ro = bool(cfg.get('ro_active', False))
@@ -2694,7 +2764,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2694
2764
  else:
2695
2765
  print("Warning: Style/geometry file was saved without --ro; current plot was created with --ro.")
2696
2766
  print("Not applying CPC style/geometry to avoid corrupting axis orientation.")
2697
- _print_menu(); continue
2767
+ _print_menu(fig); continue
2698
2768
 
2699
2769
  has_geometry = (kind == 'cpc_style_geom' and 'geometry' in cfg)
2700
2770
 
@@ -2724,7 +2794,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2724
2794
 
2725
2795
  except Exception as e:
2726
2796
  print(f"Error importing style: {e}")
2727
- _print_menu(); continue
2797
+ _print_menu(fig); continue
2728
2798
  elif key == 'ry':
2729
2799
  # Toggle efficiency visibility on the right axis
2730
2800
  try:
@@ -2833,7 +2903,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2833
2903
  fig.canvas.draw_idle()
2834
2904
  except Exception:
2835
2905
  pass
2836
- _print_menu(); continue
2906
+ _print_menu(fig); continue
2837
2907
  elif key == 'h':
2838
2908
  # Legend submenu: toggle visibility, set position in inches relative to canvas center (0,0)
2839
2909
  try:
@@ -2886,15 +2956,15 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2886
2956
  # Ensure a legend exists at the stored position
2887
2957
  H, L = _visible_handles_labels(ax, ax2)
2888
2958
  if H:
2889
- offset = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
2890
- if offset is not None:
2891
- fig._cpc_legend_xy_in = offset
2892
- _apply_legend_position()
2893
- else:
2894
- _legend_no_frame(ax, H, L, loc='best', borderaxespad=1.0)
2959
+ # Always use _rebuild_legend for consistency
2960
+ _rebuild_legend(ax, ax2, file_data, preserve_position=True)
2961
+ else:
2962
+ print("No visible legend items found.")
2895
2963
  fig.canvas.draw_idle()
2896
- except Exception:
2897
- pass
2964
+ except Exception as e:
2965
+ print(f"Error toggling legend: {e}")
2966
+ import traceback
2967
+ traceback.print_exc()
2898
2968
  elif sub == 'p':
2899
2969
  # Position submenu with x and y subcommands
2900
2970
  while True:
@@ -2920,13 +2990,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2920
2990
  continue
2921
2991
  push_state("legend-position")
2922
2992
  try:
2923
- fig._cpc_legend_xy_in = (x_in, xy_in[1])
2924
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2925
- _apply_legend_position()
2926
- fig.canvas.draw_idle()
2927
- print(f"Legend position updated: x={x_in:.2f}, y={xy_in[1]:.2f}")
2928
- except Exception:
2929
- pass
2993
+ # Sanitize and store the new position
2994
+ new_pos = _sanitize_legend_offset((x_in, xy_in[1]))
2995
+ if new_pos is not None:
2996
+ fig._cpc_legend_xy_in = new_pos
2997
+ _apply_legend_position()
2998
+ fig.canvas.draw_idle()
2999
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
3000
+ else:
3001
+ print(f"Invalid position: x={x_in:.2f} is out of bounds. Position not updated.")
3002
+ except Exception as e:
3003
+ print(f"Error updating legend position: {e}")
2930
3004
  elif pos_cmd == 'y':
2931
3005
  # Y only: stay in loop
2932
3006
  while True:
@@ -2943,13 +3017,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2943
3017
  continue
2944
3018
  push_state("legend-position")
2945
3019
  try:
2946
- fig._cpc_legend_xy_in = (xy_in[0], y_in)
2947
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2948
- _apply_legend_position()
2949
- fig.canvas.draw_idle()
2950
- print(f"Legend position updated: x={xy_in[0]:.2f}, y={y_in:.2f}")
2951
- except Exception:
2952
- pass
3020
+ # Sanitize and store the new position
3021
+ new_pos = _sanitize_legend_offset((xy_in[0], y_in))
3022
+ if new_pos is not None:
3023
+ fig._cpc_legend_xy_in = new_pos
3024
+ _apply_legend_position()
3025
+ fig.canvas.draw_idle()
3026
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
3027
+ else:
3028
+ print(f"Invalid position: y={y_in:.2f} is out of bounds. Position not updated.")
3029
+ except Exception as e:
3030
+ print(f"Error updating legend position: {e}")
2953
3031
  else:
2954
3032
  # Try to parse as "x y" format
2955
3033
  parts = pos_cmd.replace(',', ' ').split()
@@ -2961,13 +3039,17 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2961
3039
  print("Invalid numbers."); continue
2962
3040
  push_state("legend-position")
2963
3041
  try:
2964
- fig._cpc_legend_xy_in = (x_in, y_in)
2965
- fig._cpc_legend_xy_in = _sanitize_legend_offset(fig._cpc_legend_xy_in)
2966
- _apply_legend_position()
2967
- fig.canvas.draw_idle()
2968
- print(f"Legend position updated: x={x_in:.2f}, y={y_in:.2f}")
2969
- except Exception:
2970
- pass
3042
+ # Sanitize and store the new position
3043
+ new_pos = _sanitize_legend_offset((x_in, y_in))
3044
+ if new_pos is not None:
3045
+ fig._cpc_legend_xy_in = new_pos
3046
+ _apply_legend_position()
3047
+ fig.canvas.draw_idle()
3048
+ print(f"Legend position updated: x={new_pos[0]:.2f}, y={new_pos[1]:.2f}")
3049
+ else:
3050
+ print(f"Invalid position: x={x_in:.2f}, y={y_in:.2f} is out of bounds. Position not updated.")
3051
+ except Exception as e:
3052
+ print(f"Error updating legend position: {e}")
2971
3053
  else:
2972
3054
  print("Unknown option.")
2973
3055
  except Exception:
@@ -3206,28 +3288,46 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3206
3288
  except Exception:
3207
3289
  e_ms = 40
3208
3290
  print(f" charge ms={c_ms}, discharge ms={d_ms}, efficiency ms={e_ms}")
3209
- spec = _safe_input("Set marker size: 'c <ms>', 'd <ms>', 'e <ms>' (q=cancel): ").strip().lower()
3291
+ spec = _safe_input("Set new marker size for all series (q=cancel): ").strip().lower()
3210
3292
  if not spec or spec == 'q':
3211
- _print_menu(); continue
3212
- parts = spec.split()
3213
- if len(parts) != 2:
3214
- print("Need two tokens."); _print_menu(); continue
3215
- role, val = parts[0], parts[1]
3293
+ _print_menu(fig); continue
3216
3294
  try:
3217
- num = float(val)
3295
+ num = float(spec)
3218
3296
  push_state("marker-size")
3219
- if role == 'c' and hasattr(sc_charge, 'set_sizes'):
3297
+ # Apply to current file's artists
3298
+ if hasattr(sc_charge, 'set_sizes'):
3220
3299
  sc_charge.set_sizes([num])
3221
- elif role == 'd' and hasattr(sc_discharge, 'set_sizes'):
3300
+ if hasattr(sc_discharge, 'set_sizes'):
3222
3301
  sc_discharge.set_sizes([num])
3223
- elif role == 'e' and hasattr(sc_eff, 'set_sizes'):
3302
+ if hasattr(sc_eff, 'set_sizes'):
3224
3303
  sc_eff.set_sizes([num])
3304
+ # In multi-file mode, also apply to all files' capacity/efficiency
3305
+ if is_multi_file and file_data:
3306
+ for f in file_data:
3307
+ ch = f.get('sc_charge')
3308
+ dh = f.get('sc_discharge')
3309
+ ef = f.get('sc_eff')
3310
+ try:
3311
+ if ch is not None and hasattr(ch, 'set_sizes'):
3312
+ ch.set_sizes([num])
3313
+ except Exception:
3314
+ pass
3315
+ try:
3316
+ if dh is not None and hasattr(dh, 'set_sizes'):
3317
+ dh.set_sizes([num])
3318
+ except Exception:
3319
+ pass
3320
+ try:
3321
+ if ef is not None and hasattr(ef, 'set_sizes'):
3322
+ ef.set_sizes([num])
3323
+ except Exception:
3324
+ pass
3225
3325
  fig.canvas.draw_idle()
3226
3326
  except Exception:
3227
3327
  print("Invalid value.")
3228
3328
  except Exception as e:
3229
3329
  print(f"Error: {e}")
3230
- _print_menu(); continue
3330
+ _print_menu(fig); continue
3231
3331
  elif key == 't':
3232
3332
  # Unified WASD toggles for spines/ticks/minor/labels/title per side
3233
3333
  # Import UI positioning functions locally to ensure they're accessible in nested functions
@@ -3809,7 +3909,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3809
3909
  fig.canvas.draw_idle()
3810
3910
  except Exception as e:
3811
3911
  print(f"Error in WASD tick menu: {e}")
3812
- _print_menu(); continue
3912
+ _print_menu(fig); continue
3813
3913
  elif key == 'g':
3814
3914
  while True:
3815
3915
  print("Geometry: p=plot frame, c=canvas, q=back")
@@ -3831,7 +3931,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
3831
3931
  resize_canvas(fig, ax)
3832
3932
  except Exception as e:
3833
3933
  print(f"Resize failed: {e}")
3834
- _print_menu(); continue
3934
+ _print_menu(fig); continue
3835
3935
  elif key == 'r':
3836
3936
  # Rename axis titles
3837
3937
  print("Tip: Use LaTeX/mathtext for special characters:")
@@ -4117,7 +4217,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4117
4217
  print(f"Error: {e}")
4118
4218
  else:
4119
4219
  print("Unknown option.")
4120
- _print_menu(); continue
4220
+ _print_menu(fig); continue
4121
4221
  elif key == 'x':
4122
4222
  while True:
4123
4223
  current_xlim = ax.get_xlim()
@@ -4216,7 +4316,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4216
4316
  fig.canvas.draw_idle()
4217
4317
  except Exception:
4218
4318
  print("Invalid numbers.")
4219
- _print_menu(); continue
4319
+ _print_menu(fig); continue
4220
4320
  elif key == 'y':
4221
4321
  while True:
4222
4322
  print("Y-ranges: ly=left axis, ry=right axis, q=back")
@@ -4422,10 +4522,68 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
4422
4522
  fig.canvas.draw_idle()
4423
4523
  except Exception:
4424
4524
  print("Invalid numbers.")
4425
- _print_menu(); continue
4525
+ _print_menu(fig); continue
4526
+ elif key == 'ie':
4527
+ # Invert coulombic efficiency values around 100% for the current file(s)
4528
+ try:
4529
+ if sc_eff is None or not hasattr(sc_eff, 'get_offsets'):
4530
+ print("No efficiency data to invert.")
4531
+ _print_menu(fig); continue
4532
+ if is_multi_file:
4533
+ _print_file_list(file_data, current_file_idx)
4534
+ choice = _safe_input(
4535
+ f"Invert efficiency for file (1-{len(file_data)}), 'a' for all, or q=cancel: "
4536
+ ).strip().lower()
4537
+ if not choice or choice == 'q':
4538
+ _print_menu(fig); continue
4539
+ targets = []
4540
+ if choice == 'a':
4541
+ targets = list(range(len(file_data)))
4542
+ else:
4543
+ try:
4544
+ idx = int(choice) - 1
4545
+ if 0 <= idx < len(file_data):
4546
+ targets = [idx]
4547
+ else:
4548
+ print("Invalid file number.")
4549
+ _print_menu(fig); continue
4550
+ except ValueError:
4551
+ print("Invalid choice.")
4552
+ _print_menu(fig); continue
4553
+ push_state("invert-efficiency")
4554
+ for idx in targets:
4555
+ f = file_data[idx]
4556
+ eff_sc = f.get('sc_eff')
4557
+ if eff_sc is None or not hasattr(eff_sc, 'get_offsets'):
4558
+ continue
4559
+ offsets = eff_sc.get_offsets()
4560
+ if offsets.size == 0:
4561
+ continue
4562
+ xs = offsets[:, 0]
4563
+ ys = offsets[:, 1]
4564
+ # Invert around 100% (y -> 100 - y + 100 = 200 - y)
4565
+ new_ys = 200.0 - ys
4566
+ eff_sc.set_offsets(list(zip(xs, new_ys)))
4567
+ fig.canvas.draw_idle()
4568
+ print("Inverted efficiency for selected file(s).")
4569
+ else:
4570
+ offsets = sc_eff.get_offsets()
4571
+ if offsets.size == 0:
4572
+ print("No efficiency data to invert.")
4573
+ _print_menu(fig); continue
4574
+ xs = offsets[:, 0]
4575
+ ys = offsets[:, 1]
4576
+ push_state("invert-efficiency")
4577
+ new_ys = 200.0 - ys
4578
+ sc_eff.set_offsets(list(zip(xs, new_ys)))
4579
+ fig.canvas.draw_idle()
4580
+ print("Inverted efficiency for current dataset.")
4581
+ except Exception as e:
4582
+ print(f"Error in efficiency inversion: {e}")
4583
+ _print_menu(fig); continue
4426
4584
  else:
4427
4585
  print("Unknown key.")
4428
- _print_menu(); continue
4586
+ _print_menu(fig); continue
4429
4587
 
4430
4588
 
4431
4589
  __all__ = ["cpc_interactive_menu"]