batplot 1.7.23__tar.gz → 1.7.24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (60) hide show
  1. {batplot-1.7.23/batplot.egg-info → batplot-1.7.24}/PKG-INFO +23 -2
  2. {batplot-1.7.23 → batplot-1.7.24}/batplot/__init__.py +1 -1
  3. {batplot-1.7.23 → batplot-1.7.24}/batplot/cpc_interactive.py +168 -11
  4. {batplot-1.7.23 → batplot-1.7.24}/batplot/electrochem_interactive.py +152 -14
  5. {batplot-1.7.23 → batplot-1.7.24}/batplot/interactive.py +156 -30
  6. {batplot-1.7.23 → batplot-1.7.24}/batplot/operando_ec_interactive.py +162 -11
  7. {batplot-1.7.23 → batplot-1.7.24}/batplot/session.py +84 -0
  8. {batplot-1.7.23 → batplot-1.7.24}/batplot/style.py +109 -47
  9. {batplot-1.7.23 → batplot-1.7.24/batplot.egg-info}/PKG-INFO +23 -2
  10. {batplot-1.7.23 → batplot-1.7.24}/pyproject.toml +2 -3
  11. {batplot-1.7.23 → batplot-1.7.24}/LICENSE +0 -0
  12. {batplot-1.7.23 → batplot-1.7.24}/MANIFEST.in +0 -0
  13. {batplot-1.7.23 → batplot-1.7.24}/README.md +0 -0
  14. {batplot-1.7.23 → batplot-1.7.24}/USER_MANUAL.md +0 -0
  15. {batplot-1.7.23 → batplot-1.7.24}/batplot/args.py +0 -0
  16. {batplot-1.7.23 → batplot-1.7.24}/batplot/batch.py +0 -0
  17. {batplot-1.7.23 → batplot-1.7.24}/batplot/batplot.py +0 -0
  18. {batplot-1.7.23 → batplot-1.7.24}/batplot/cif.py +0 -0
  19. {batplot-1.7.23 → batplot-1.7.24}/batplot/cli.py +0 -0
  20. {batplot-1.7.23 → batplot-1.7.24}/batplot/color_utils.py +0 -0
  21. {batplot-1.7.23 → batplot-1.7.24}/batplot/config.py +0 -0
  22. {batplot-1.7.23 → batplot-1.7.24}/batplot/converters.py +0 -0
  23. {batplot-1.7.23 → batplot-1.7.24}/batplot/data/USER_MANUAL.md +0 -0
  24. {batplot-1.7.23 → batplot-1.7.24}/batplot/manual.py +0 -0
  25. {batplot-1.7.23 → batplot-1.7.24}/batplot/modes.py +0 -0
  26. {batplot-1.7.23 → batplot-1.7.24}/batplot/operando.py +0 -0
  27. {batplot-1.7.23 → batplot-1.7.24}/batplot/plotting.py +0 -0
  28. {batplot-1.7.23 → batplot-1.7.24}/batplot/readers.py +0 -0
  29. {batplot-1.7.23 → batplot-1.7.24}/batplot/ui.py +0 -0
  30. {batplot-1.7.23 → batplot-1.7.24}/batplot/utils.py +0 -0
  31. {batplot-1.7.23 → batplot-1.7.24}/batplot/version_check.py +0 -0
  32. {batplot-1.7.23 → batplot-1.7.24}/batplot.egg-info/SOURCES.txt +0 -0
  33. {batplot-1.7.23 → batplot-1.7.24}/batplot.egg-info/dependency_links.txt +0 -0
  34. {batplot-1.7.23 → batplot-1.7.24}/batplot.egg-info/entry_points.txt +0 -0
  35. {batplot-1.7.23 → batplot-1.7.24}/batplot.egg-info/requires.txt +0 -0
  36. {batplot-1.7.23 → batplot-1.7.24}/batplot.egg-info/top_level.txt +0 -0
  37. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/__init__.py +0 -0
  38. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/args.py +0 -0
  39. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/batch.py +0 -0
  40. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/batplot.py +0 -0
  41. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/cif.py +0 -0
  42. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/cli.py +0 -0
  43. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/color_utils.py +0 -0
  44. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/config.py +0 -0
  45. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/converters.py +0 -0
  46. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/cpc_interactive.py +0 -0
  47. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/electrochem_interactive.py +0 -0
  48. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/interactive.py +0 -0
  49. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/modes.py +0 -0
  50. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/operando.py +0 -0
  51. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/operando_ec_interactive.py +0 -0
  52. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/plotting.py +0 -0
  53. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/readers.py +0 -0
  54. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/session.py +0 -0
  55. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/style.py +0 -0
  56. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/ui.py +0 -0
  57. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/utils.py +0 -0
  58. {batplot-1.7.23 → batplot-1.7.24}/batplot_backup_20251121_223043/version_check.py +0 -0
  59. {batplot-1.7.23 → batplot-1.7.24}/setup.cfg +0 -0
  60. {batplot-1.7.23 → batplot-1.7.24}/setup.py +0 -0
@@ -1,9 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.7.23
3
+ Version: 1.7.24
4
4
  Summary: Interactive plotting tool for material science (1D plot) and electrochemistry (GC, CV, dQ/dV, CPC, operando) with batch processing
5
5
  Author-email: Tian Dai <tianda@uio.no>
6
- License-Expression: MIT
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Tian Dai
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
7
28
  Project-URL: Homepage, https://github.com/TianDai1729/batplot
8
29
  Project-URL: Repository, https://github.com/TianDai1729/batplot
9
30
  Project-URL: Issues, https://github.com/TianDai1729/batplot/issues
@@ -1,5 +1,5 @@
1
1
  """batplot: Interactive plotting for battery data visualization."""
2
2
 
3
- __version__ = "1.7.23"
3
+ __version__ = "1.7.24"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -79,6 +79,46 @@ def _colorize_menu(text):
79
79
  return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
80
80
 
81
81
 
82
+ def _color_of(artist):
83
+ """Return a representative color for a Line2D/PathCollection."""
84
+ try:
85
+ if artist is None:
86
+ return None
87
+ if hasattr(artist, 'get_color'):
88
+ c = artist.get_color()
89
+ if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
90
+ return c[0]
91
+ return c
92
+ if hasattr(artist, 'get_facecolors'):
93
+ arr = artist.get_facecolors()
94
+ if arr is not None and len(arr):
95
+ from matplotlib.colors import to_hex
96
+ return to_hex(arr[0])
97
+ except Exception:
98
+ return None
99
+ return None
100
+
101
+
102
+ def _get_legend_title(fig, default: str = "Legend") -> str:
103
+ """Fetch stored legend title, falling back to current legend text or default."""
104
+ try:
105
+ title = getattr(fig, '_cpc_legend_title', None)
106
+ if isinstance(title, str) and title:
107
+ return title
108
+ except Exception:
109
+ pass
110
+ try:
111
+ for ax in getattr(fig, 'axes', []):
112
+ leg = ax.get_legend()
113
+ if leg is not None:
114
+ t = leg.get_title().get_text()
115
+ if t:
116
+ return t
117
+ except Exception:
118
+ pass
119
+ return default
120
+
121
+
82
122
  def _colorize_prompt(text):
83
123
  """Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
84
124
  import re
@@ -234,7 +274,7 @@ def _rebuild_legend(ax, ax2, file_data):
234
274
  h_all.append(h)
235
275
  l_all.append(l)
236
276
  if h_all:
237
- _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0)
277
+ _legend_no_frame(ax, h_all, l_all, loc='best', borderaxespad=1.0, title=_get_legend_title(ax.figure))
238
278
  else:
239
279
  leg = ax.get_legend()
240
280
  if leg:
@@ -398,7 +438,8 @@ def _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data=Non
398
438
  'font': {'family': fam0, 'size': fsize},
399
439
  'legend': {
400
440
  'visible': legend_visible,
401
- 'position_inches': legend_xy_in # [x, y] offset from canvas center in inches
441
+ 'position_inches': legend_xy_in, # [x, y] offset from canvas center in inches
442
+ 'title': _get_legend_title(fig),
402
443
  },
403
444
  'ticks': {
404
445
  'widths': {
@@ -671,6 +712,8 @@ def _apply_style(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, cfg: Dict, file_
671
712
  if leg_cfg:
672
713
  leg_visible = leg_cfg.get('visible', True)
673
714
  leg_xy_in = leg_cfg.get('position_inches')
715
+ if 'title' in leg_cfg:
716
+ fig._cpc_legend_title = leg_cfg.get('title') or _get_legend_title(fig)
674
717
  if leg_xy_in is not None:
675
718
  fig._cpc_legend_xy_in = _sanitize_legend_offset(tuple(leg_xy_in))
676
719
  leg = ax.get_legend()
@@ -1197,7 +1240,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1197
1240
  try:
1198
1241
  xy_in = _sanitize_legend_offset(getattr(fig, '_cpc_legend_xy_in', None))
1199
1242
  leg = ax.get_legend()
1200
- if xy_in is None or leg is None:
1243
+ if xy_in is None:
1201
1244
  return
1202
1245
  # Compute figure-fraction anchor from inches
1203
1246
  fw, fh = fig.get_size_inches()
@@ -1209,7 +1252,16 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1209
1252
  h1, l1 = ax.get_legend_handles_labels()
1210
1253
  h2, l2 = ax2.get_legend_handles_labels()
1211
1254
  if h1 or h2:
1212
- _legend_no_frame(ax, h1 + h2, l1 + l2, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
1255
+ _legend_no_frame(
1256
+ ax,
1257
+ h1 + h2,
1258
+ l1 + l2,
1259
+ loc='center',
1260
+ bbox_to_anchor=(fx, fy),
1261
+ bbox_transform=fig.transFigure,
1262
+ borderaxespad=1.0,
1263
+ title=_get_legend_title(fig),
1264
+ )
1213
1265
  except Exception:
1214
1266
  pass
1215
1267
 
@@ -1680,12 +1732,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1680
1732
  else:
1681
1733
  print(f" {i}: {fname}")
1682
1734
 
1683
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1735
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
1736
+ if last_figure_path:
1737
+ fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1738
+ else:
1739
+ fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1684
1740
  if not fname or fname.lower() == 'q':
1685
1741
  _print_menu(); continue
1686
1742
 
1743
+ # Check for 'o' option
1744
+ if fname.lower() == 'o':
1745
+ if not last_figure_path:
1746
+ print("No previous export found.")
1747
+ _print_menu(); continue
1748
+ if not os.path.exists(last_figure_path):
1749
+ print(f"Previous export file not found: {last_figure_path}")
1750
+ _print_menu(); continue
1751
+ yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1752
+ if yn != 'y':
1753
+ _print_menu(); continue
1754
+ target = last_figure_path
1687
1755
  # Check if user selected a number
1688
- if fname.isdigit() and files:
1756
+ elif fname.isdigit() and files:
1689
1757
  idx = int(fname)
1690
1758
  if 1 <= idx <= len(files):
1691
1759
  name = files[idx-1]
@@ -1734,6 +1802,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1734
1802
  # Export the figure
1735
1803
  fig.savefig(target, bbox_inches='tight')
1736
1804
  print(f"Exported figure to {target}")
1805
+ fig._last_figure_export_path = target
1737
1806
 
1738
1807
  # Restore original labels
1739
1808
  if is_multi_file and original_labels:
@@ -1767,10 +1836,28 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1767
1836
  print(f" {i}: {f} ({timestamp})")
1768
1837
  else:
1769
1838
  print(f" {i}: {f}")
1770
- prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1839
+ last_session_path = getattr(fig, '_last_session_save_path', None)
1840
+ if last_session_path:
1841
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
1842
+ else:
1843
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1771
1844
  choice = input(prompt).strip()
1772
1845
  if not choice or choice.lower() == 'q':
1773
1846
  _print_menu(); continue
1847
+ if choice.lower() == 'o':
1848
+ # Overwrite last saved session
1849
+ if not last_session_path:
1850
+ print("No previous save found.")
1851
+ _print_menu(); continue
1852
+ if not os.path.exists(last_session_path):
1853
+ print(f"Previous save file not found: {last_session_path}")
1854
+ _print_menu(); continue
1855
+ yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
1856
+ if yn != 'y':
1857
+ _print_menu(); continue
1858
+ 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)
1859
+ print(f"Overwritten session to {last_session_path}")
1860
+ _print_menu(); continue
1774
1861
  if choice.isdigit() and files:
1775
1862
  idx = int(choice)
1776
1863
  if 1 <= idx <= len(files):
@@ -1779,10 +1866,13 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1779
1866
  if yn != 'y':
1780
1867
  _print_menu(); continue
1781
1868
  target = os.path.join(folder, name)
1869
+ 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)
1870
+ fig._last_session_save_path = target
1871
+ _print_menu(); continue
1782
1872
  else:
1783
1873
  print("Invalid number.")
1784
1874
  _print_menu(); continue
1785
- else:
1875
+ if choice.lower() != 'o':
1786
1876
  name = choice
1787
1877
  root, ext = os.path.splitext(name)
1788
1878
  if ext == '':
@@ -1792,7 +1882,8 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1792
1882
  yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
1793
1883
  if yn != 'y':
1794
1884
  _print_menu(); continue
1795
- dump_cpc_session(target, fig=fig, ax=ax, ax2=ax2, sc_charge=sc_charge, sc_discharge=sc_discharge, sc_eff=sc_eff, file_data=file_data, skip_confirm=True)
1885
+ 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)
1886
+ fig._last_session_save_path = target
1796
1887
  except Exception as e:
1797
1888
  print(f"Save failed: {e}")
1798
1889
  _print_menu(); continue
@@ -1916,11 +2007,44 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1916
2007
  else:
1917
2008
  print(f" {_i}: {fname}")
1918
2009
 
1919
- sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
2010
+ last_style_path = getattr(fig, '_last_style_export_path', None)
2011
+ if last_style_path:
2012
+ sub = input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
2013
+ else:
2014
+ sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1920
2015
  if sub == 'q':
1921
2016
  break
1922
2017
  if sub == 'r' or sub == '':
1923
2018
  continue
2019
+ if sub == 'o':
2020
+ # Overwrite last exported style file
2021
+ if not last_style_path:
2022
+ print("No previous export found.")
2023
+ continue
2024
+ if not os.path.exists(last_style_path):
2025
+ print(f"Previous export file not found: {last_style_path}")
2026
+ continue
2027
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2028
+ if yn != 'y':
2029
+ continue
2030
+ # Rebuild config based on current state
2031
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2032
+ # Determine if last export was style-only or style+geometry
2033
+ try:
2034
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2035
+ old_cfg = json.load(f)
2036
+ if old_cfg.get('kind') == 'cpc_style_geom':
2037
+ snap['kind'] = 'cpc_style_geom'
2038
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2039
+ else:
2040
+ snap['kind'] = 'cpc_style'
2041
+ except Exception:
2042
+ snap['kind'] = 'cpc_style'
2043
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2044
+ json.dump(snap, f, indent=2)
2045
+ print(f"Overwritten style to {last_style_path}")
2046
+ style_menu_active = False
2047
+ break
1924
2048
  if sub == 'e':
1925
2049
  # Ask for ps or psg
1926
2050
  print("Export options:")
@@ -1978,10 +2102,42 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
1978
2102
  print(f" {i}: {fname} ({timestamp})")
1979
2103
  else:
1980
2104
  print(f" {i}: {fname}")
1981
- choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
2105
+ if last_style_path:
2106
+ choice = input("Enter new filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
2107
+ else:
2108
+ choice = input("Enter new filename or number to overwrite (q=cancel): ").strip()
1982
2109
  if not choice or choice.lower() == 'q':
1983
2110
  print("Style export canceled.")
1984
2111
  continue
2112
+ if choice.lower() == 'o':
2113
+ # Overwrite last exported style file
2114
+ if not last_style_path:
2115
+ print("No previous export found.")
2116
+ continue
2117
+ if not os.path.exists(last_style_path):
2118
+ print(f"Previous export file not found: {last_style_path}")
2119
+ continue
2120
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
2121
+ if yn != 'y':
2122
+ continue
2123
+ # Rebuild config based on current state
2124
+ snap = _style_snapshot(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2125
+ # Determine if last export was style-only or style+geometry
2126
+ try:
2127
+ with open(last_style_path, 'r', encoding='utf-8') as f:
2128
+ old_cfg = json.load(f)
2129
+ if old_cfg.get('kind') == 'cpc_style_geom':
2130
+ snap['kind'] = 'cpc_style_geom'
2131
+ snap['geometry'] = _get_geometry_snapshot(ax, ax2)
2132
+ else:
2133
+ snap['kind'] = 'cpc_style'
2134
+ except Exception:
2135
+ snap['kind'] = 'cpc_style'
2136
+ with open(last_style_path, 'w', encoding='utf-8') as f:
2137
+ json.dump(snap, f, indent=2)
2138
+ print(f"Overwritten style to {last_style_path}")
2139
+ style_menu_active = False
2140
+ break
1985
2141
  target = None
1986
2142
  if choice.isdigit() and files:
1987
2143
  idx = int(choice)
@@ -2011,6 +2167,7 @@ def cpc_interactive_menu(fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_dat
2011
2167
  with open(target, 'w', encoding='utf-8') as f:
2012
2168
  json.dump(snap, f, indent=2)
2013
2169
  print(f"Exported CPC style to {target}")
2170
+ fig._last_style_export_path = target
2014
2171
  style_menu_active = False # Exit style submenu and return to main menu
2015
2172
  break
2016
2173
  else:
@@ -468,6 +468,8 @@ def _get_legend_title(fig, default: str = "Cycle") -> str:
468
468
  def _rebuild_legend(ax):
469
469
  """Rebuild legend using only visible lines, anchoring to absolute inches from canvas center if available."""
470
470
  fig = ax.figure
471
+ # Capture existing title before any rebuild so it isn't lost
472
+ _store_legend_title(fig, ax)
471
473
  if not _get_legend_user_pref(fig):
472
474
  leg = ax.get_legend()
473
475
  if leg is not None:
@@ -1271,6 +1273,10 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1271
1273
  snap['legend']['visible'] = bool(leg_obj.get_visible()) if leg_obj is not None else False
1272
1274
  except Exception:
1273
1275
  pass
1276
+ try:
1277
+ snap['legend']['title'] = _get_legend_title(fig)
1278
+ except Exception:
1279
+ snap['legend']['title'] = None
1274
1280
  try:
1275
1281
  legend_xy = getattr(fig, '_ec_legend_xy_in', None)
1276
1282
  if legend_xy is not None:
@@ -1299,7 +1305,33 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1299
1305
  if len(state_history) > 40:
1300
1306
  state_history.pop(0)
1301
1307
  except Exception:
1302
- pass
1308
+ # Minimal fallback so undo still works if full snapshot fails
1309
+ try:
1310
+ fallback = {
1311
+ 'note': f"{note}-fallback",
1312
+ 'xlim': ax.get_xlim(),
1313
+ 'ylim': ax.get_ylim(),
1314
+ 'legend': {
1315
+ 'visible': bool(ax.get_legend().get_visible()) if ax.get_legend() else False,
1316
+ 'position_inches': getattr(fig, '_ec_legend_xy_in', None),
1317
+ 'title': _get_legend_title(fig),
1318
+ },
1319
+ 'lines': []
1320
+ }
1321
+ for i, ln in enumerate(ax.lines):
1322
+ try:
1323
+ fallback['lines'].append({
1324
+ 'index': i,
1325
+ 'color': ln.get_color(),
1326
+ 'visible': ln.get_visible(),
1327
+ })
1328
+ except Exception:
1329
+ fallback['lines'].append({'index': i})
1330
+ state_history.append(fallback)
1331
+ if len(state_history) > 40:
1332
+ state_history.pop(0)
1333
+ except Exception:
1334
+ pass
1303
1335
 
1304
1336
  def restore_state():
1305
1337
  if not state_history:
@@ -1529,6 +1561,8 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1529
1561
  legend_snap = snap.get('legend', {})
1530
1562
  if legend_snap:
1531
1563
  try:
1564
+ if 'title' in legend_snap:
1565
+ fig._ec_legend_title = legend_snap.get('title') or _get_legend_title(fig)
1532
1566
  xy = legend_snap.get('position_inches')
1533
1567
  fig._ec_legend_xy_in = _sanitize_legend_offset(fig, xy) if xy is not None else None
1534
1568
  except Exception:
@@ -1594,14 +1628,35 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1594
1628
  else:
1595
1629
  print(f" {i}: {fname}")
1596
1630
 
1597
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1631
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
1632
+ if last_figure_path:
1633
+ fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1634
+ else:
1635
+ fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1598
1636
  if not fname or fname.lower() == 'q':
1599
1637
  _print_menu(len(all_cycles), is_dqdv)
1600
1638
  continue
1601
1639
 
1640
+ already_confirmed = False # Initialize for new filename case
1641
+ # Check for 'o' option
1642
+ if fname.lower() == 'o':
1643
+ if not last_figure_path:
1644
+ print("No previous export found.")
1645
+ _print_menu(len(all_cycles), is_dqdv)
1646
+ continue
1647
+ if not os.path.exists(last_figure_path):
1648
+ print(f"Previous export file not found: {last_figure_path}")
1649
+ _print_menu(len(all_cycles), is_dqdv)
1650
+ continue
1651
+ yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1652
+ if yn != 'y':
1653
+ _print_menu(len(all_cycles), is_dqdv)
1654
+ continue
1655
+ target = last_figure_path
1656
+ already_confirmed = True
1602
1657
  # Check if user selected a number
1603
- already_confirmed = False
1604
- if fname.isdigit() and files:
1658
+ elif fname.isdigit() and files:
1659
+ already_confirmed = False
1605
1660
  idx = int(fname)
1606
1661
  if 1 <= idx <= len(files):
1607
1662
  name = files[idx-1]
@@ -1671,6 +1726,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1671
1726
  else:
1672
1727
  fig.savefig(target, bbox_inches='tight')
1673
1728
  print(f"Exported figure to {target}")
1729
+ fig._last_figure_export_path = target
1674
1730
  except Exception as e:
1675
1731
  print(f"Export failed: {e}")
1676
1732
  except Exception as e:
@@ -1854,11 +1910,45 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1854
1910
  else:
1855
1911
  print(f" {_i}: {fname}")
1856
1912
 
1857
- sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1913
+ last_style_path = getattr(fig, '_last_style_export_path', None)
1914
+ if last_style_path:
1915
+ sub = input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
1916
+ else:
1917
+ sub = input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
1858
1918
  if sub == 'q':
1859
1919
  break
1860
1920
  if sub == 'r' or sub == '':
1861
1921
  continue
1922
+ if sub == 'o':
1923
+ # Overwrite last exported style file
1924
+ if not last_style_path:
1925
+ print("No previous export found.")
1926
+ continue
1927
+ if not os.path.exists(last_style_path):
1928
+ print(f"Previous export file not found: {last_style_path}")
1929
+ continue
1930
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
1931
+ if yn != 'y':
1932
+ continue
1933
+ # Rebuild config based on current state
1934
+ cfg = _get_style_snapshot(fig, ax, cycle_lines, tick_state)
1935
+ # Determine if last export was style-only or style+geometry
1936
+ try:
1937
+ with open(last_style_path, 'r', encoding='utf-8') as f:
1938
+ old_cfg = json.load(f)
1939
+ if old_cfg.get('kind') == 'ec_style_geom':
1940
+ geom = _get_geometry_snapshot(fig, ax)
1941
+ cfg['kind'] = 'ec_style_geom'
1942
+ cfg['geometry'] = geom
1943
+ else:
1944
+ cfg['kind'] = 'ec_style'
1945
+ except Exception:
1946
+ cfg['kind'] = 'ec_style'
1947
+ with open(last_style_path, 'w', encoding='utf-8') as f:
1948
+ json.dump(cfg, f, indent=2)
1949
+ print(f"Overwritten style to {last_style_path}")
1950
+ style_menu_active = False
1951
+ break
1862
1952
  if sub == 'e':
1863
1953
  # Ask for ps or psg
1864
1954
  print("Export options:")
@@ -1895,7 +1985,9 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
1895
1985
  print("Style export canceled.")
1896
1986
  continue
1897
1987
  print(f"\nChosen path: {save_base}")
1898
- _export_style_dialog(cfg, default_ext=default_ext, base_path=save_base)
1988
+ exported_path = _export_style_dialog(cfg, default_ext=default_ext, base_path=save_base)
1989
+ if exported_path:
1990
+ fig._last_style_export_path = exported_path
1899
1991
  style_menu_active = False # Exit style submenu and return to main menu
1900
1992
  break
1901
1993
  else:
@@ -2197,6 +2289,19 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2197
2289
  except Exception as e:
2198
2290
  print(f"Warning: Could not apply geometry: {e}")
2199
2291
 
2292
+ # Restore title offsets
2293
+ try:
2294
+ offsets = cfg.get('title_offsets', {})
2295
+ if offsets:
2296
+ ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top_y', 0.0) or 0.0)
2297
+ ax._top_xlabel_manual_offset_x_pts = float(offsets.get('top_x', 0.0) or 0.0)
2298
+ ax._bottom_xlabel_manual_offset_y_pts = float(offsets.get('bottom_y', 0.0) or 0.0)
2299
+ ax._left_ylabel_manual_offset_x_pts = float(offsets.get('left_x', 0.0) or 0.0)
2300
+ ax._right_ylabel_manual_offset_x_pts = float(offsets.get('right_x', 0.0) or 0.0)
2301
+ ax._right_ylabel_manual_offset_y_pts = float(offsets.get('right_y', 0.0) or 0.0)
2302
+ except Exception:
2303
+ pass
2304
+
2200
2305
  # Final label positioning - do this AFTER all style changes to prevent drift
2201
2306
  # Set pending labelpad before repositioning to preserve original values
2202
2307
  try:
@@ -2210,12 +2315,11 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2210
2315
  font_cfg = cfg.get('font', {})
2211
2316
  font_changed = (font_cfg.get('family') is not None or font_cfg.get('size') is not None)
2212
2317
 
2213
- if axes_position_changed or font_changed:
2214
- # Reposition titles (will use _pending_xlabelpad if set, preserving original labelpad)
2215
- _ui_position_top_xlabel(ax, fig, tick_state)
2216
- _ui_position_bottom_xlabel(ax, fig, tick_state)
2217
- _ui_position_left_ylabel(ax, fig, tick_state)
2218
- _ui_position_right_ylabel(ax, fig, tick_state)
2318
+ # Always reposition titles to apply offsets (even if nothing else changed)
2319
+ _ui_position_top_xlabel(ax, fig, tick_state)
2320
+ _ui_position_bottom_xlabel(ax, fig, tick_state)
2321
+ _ui_position_left_ylabel(ax, fig, tick_state)
2322
+ _ui_position_right_ylabel(ax, fig, tick_state)
2219
2323
 
2220
2324
  # Always ensure labelpad is exactly as it was before style import
2221
2325
  # This is a final safeguard against any drift
@@ -2798,6 +2902,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2798
2902
  elif key == 's':
2799
2903
  try:
2800
2904
  from .session import dump_ec_session
2905
+ last_session_path = getattr(fig, '_last_session_save_path', None)
2801
2906
  folder = choose_save_path(source_paths, purpose="EC session save")
2802
2907
  if not folder:
2803
2908
  _print_menu(len(all_cycles), is_dqdv); continue
@@ -2815,10 +2920,27 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2815
2920
  print(f" {i}: {f} ({timestamp})")
2816
2921
  else:
2817
2922
  print(f" {i}: {f}")
2818
- prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2923
+ if last_session_path:
2924
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2925
+ else:
2926
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2819
2927
  choice = input(prompt).strip()
2820
2928
  if not choice or choice.lower() == 'q':
2821
2929
  _print_menu(len(all_cycles), is_dqdv); continue
2930
+ if choice.lower() == 'o':
2931
+ # Overwrite last saved session
2932
+ if not last_session_path:
2933
+ print("No previous save found.")
2934
+ _print_menu(len(all_cycles), is_dqdv); continue
2935
+ if not os.path.exists(last_session_path):
2936
+ print(f"Previous save file not found: {last_session_path}")
2937
+ _print_menu(len(all_cycles), is_dqdv); continue
2938
+ yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2939
+ if yn != 'y':
2940
+ _print_menu(len(all_cycles), is_dqdv); continue
2941
+ dump_ec_session(last_session_path, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
2942
+ print(f"Overwritten session to {last_session_path}")
2943
+ _print_menu(len(all_cycles), is_dqdv); continue
2822
2944
  if choice.isdigit() and files:
2823
2945
  idx = int(choice)
2824
2946
  if 1 <= idx <= len(files):
@@ -2827,10 +2949,13 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2827
2949
  if yn != 'y':
2828
2950
  _print_menu(len(all_cycles), is_dqdv); continue
2829
2951
  target = os.path.join(folder, name)
2952
+ dump_ec_session(target, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
2953
+ fig._last_session_save_path = target
2954
+ _print_menu(len(all_cycles), is_dqdv); continue
2830
2955
  else:
2831
2956
  print("Invalid number.")
2832
2957
  _print_menu(len(all_cycles), is_dqdv); continue
2833
- else:
2958
+ if choice.lower() != 'o':
2834
2959
  name = choice
2835
2960
  root, ext = os.path.splitext(name)
2836
2961
  if ext == '':
@@ -2841,6 +2966,7 @@ def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optio
2841
2966
  if yn != 'y':
2842
2967
  _print_menu(len(all_cycles), is_dqdv); continue
2843
2968
  dump_ec_session(target, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
2969
+ fig._last_session_save_path = target
2844
2970
  except Exception as e:
2845
2971
  print(f"Save failed: {e}")
2846
2972
  _print_menu(len(all_cycles), is_dqdv)
@@ -4008,6 +4134,14 @@ def _get_style_snapshot(fig, ax, cycle_lines: Dict, tick_state: Dict) -> Dict:
4008
4134
  'ticks': {'widths': tick_widths, 'direction': tick_direction},
4009
4135
  'grid': grid_enabled,
4010
4136
  'wasd_state': wasd_state,
4137
+ 'title_offsets': {
4138
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
4139
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
4140
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
4141
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
4142
+ 'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
4143
+ 'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
4144
+ },
4011
4145
  'curve_linewidth': curve_linewidth,
4012
4146
  'curve_markers': curve_marker_props,
4013
4147
  'rotation_angle': getattr(fig, '_ec_rotation_angle', 0),
@@ -4236,9 +4370,11 @@ def _export_style_dialog(cfg: Dict, default_ext: str = '.bpcfg', base_path: Opti
4236
4370
  with open(target_path, 'w', encoding='utf-8') as f:
4237
4371
  json.dump(cfg, f, indent=2)
4238
4372
  print(f"Style exported to {target_path}")
4373
+ return target_path
4239
4374
 
4240
4375
  except Exception as e:
4241
4376
  print(f"Export failed: {e}")
4377
+ return None
4242
4378
  def _legend_no_frame(ax, *args, title: Optional[str] = None, **kwargs):
4243
4379
  leg = ax.legend(*args, **kwargs)
4244
4380
  if leg is not None:
@@ -4258,6 +4394,8 @@ def _apply_legend_position(fig, ax):
4258
4394
  xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', None))
4259
4395
  if xy_in is None:
4260
4396
  return False
4397
+ # Preserve current title before rebuilding the legend
4398
+ _store_legend_title(fig, ax)
4261
4399
  handles, labels = _visible_legend_entries(ax)
4262
4400
  if not handles:
4263
4401
  return False