batplot 1.7.23__py3-none-any.whl → 1.7.25__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1716,13 +1716,31 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1716
1716
  else:
1717
1717
  print(f" {i}: {fname}")
1718
1718
 
1719
- fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1719
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
1720
+ if last_figure_path:
1721
+ fname = input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
1722
+ else:
1723
+ fname = input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
1720
1724
  if not fname or fname.lower() == 'q':
1721
1725
  print_menu(); continue
1722
1726
 
1727
+ already_confirmed = False # Initialize for new filename case
1728
+ # Check for 'o' option
1729
+ if fname.lower() == 'o':
1730
+ if not last_figure_path:
1731
+ print("No previous export found.")
1732
+ print_menu(); continue
1733
+ if not os.path.exists(last_figure_path):
1734
+ print(f"Previous export file not found: {last_figure_path}")
1735
+ print_menu(); continue
1736
+ yn = input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
1737
+ if yn != 'y':
1738
+ print_menu(); continue
1739
+ target = last_figure_path
1740
+ already_confirmed = True
1723
1741
  # Check if user selected a number
1724
- already_confirmed = False
1725
- if fname.isdigit() and files:
1742
+ elif fname.isdigit() and files:
1743
+ already_confirmed = False
1726
1744
  idx = int(fname)
1727
1745
  if 1 <= idx <= len(files):
1728
1746
  name = files[idx-1]
@@ -1782,6 +1800,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1782
1800
  else:
1783
1801
  fig.savefig(target, dpi=300)
1784
1802
  print(f"Exported figure to {target}")
1803
+ fig._last_figure_export_path = target
1785
1804
  except Exception as e:
1786
1805
  print(f"Export failed: {e}")
1787
1806
  print_menu(); continue
@@ -1985,10 +2004,28 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1985
2004
  print(f" {i}: {f} ({timestamp})")
1986
2005
  else:
1987
2006
  print(f" {i}: {f}")
1988
- prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
2007
+ last_session_path = getattr(fig, '_last_session_save_path', None)
2008
+ if last_session_path:
2009
+ prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
2010
+ else:
2011
+ prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
1989
2012
  choice = input(prompt).strip()
1990
2013
  if not choice or choice.lower() == 'q':
1991
2014
  print_menu(); continue
2015
+ if choice.lower() == 'o':
2016
+ # Overwrite last saved session
2017
+ if not last_session_path:
2018
+ print("No previous save found.")
2019
+ print_menu(); continue
2020
+ if not os.path.exists(last_session_path):
2021
+ print(f"Previous save file not found: {last_session_path}")
2022
+ print_menu(); continue
2023
+ yn = input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2024
+ if yn != 'y':
2025
+ print_menu(); continue
2026
+ dump_operando_session(last_session_path, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
2027
+ print(f"Overwritten session to {last_session_path}")
2028
+ print_menu(); continue
1992
2029
  if choice.isdigit() and files:
1993
2030
  idx = int(choice)
1994
2031
  if 1 <= idx <= len(files):
@@ -1997,10 +2034,13 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1997
2034
  if yn != 'y':
1998
2035
  print_menu(); continue
1999
2036
  target = os.path.join(folder, name)
2037
+ dump_operando_session(target, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
2038
+ fig._last_session_save_path = target
2039
+ print_menu(); continue
2000
2040
  else:
2001
2041
  print("Invalid number.")
2002
2042
  print_menu(); continue
2003
- else:
2043
+ if choice.lower() != 'o':
2004
2044
  name = choice
2005
2045
  root, ext = os.path.splitext(name)
2006
2046
  if ext == '':
@@ -2010,7 +2050,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2010
2050
  yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2011
2051
  if yn != 'y':
2012
2052
  print_menu(); continue
2013
- dump_operando_session(target, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
2053
+ dump_operando_session(target, fig=fig, ax=ax, im=im, cbar=cbar, ec_ax=ec_ax, skip_confirm=True)
2054
+ fig._last_session_save_path = target
2014
2055
  except Exception as e:
2015
2056
  print(f"Save failed: {e}")
2016
2057
  print_menu(); continue
@@ -3463,6 +3504,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3463
3504
  else:
3464
3505
  print(f" {_i}: {fname}")
3465
3506
 
3507
+ last_style_path = getattr(fig, '_last_style_export_path', None)
3466
3508
  if ec_ax is None:
3467
3509
  print("\nNote: Style export (.bps/.bpsg) is only available in dual-pane mode (with EC file).")
3468
3510
  sub = input("Style submenu: (q=return, r=refresh): ").strip().lower()
@@ -3474,11 +3516,38 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3474
3516
  print("Unknown choice.")
3475
3517
  continue
3476
3518
  else:
3477
- sub = input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3519
+ if last_style_path:
3520
+ sub = input("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ").strip().lower()
3521
+ else:
3522
+ sub = input("Style submenu: (e=export, q=return, r=refresh): ").strip().lower()
3478
3523
  if sub == 'q':
3479
3524
  break
3480
3525
  if sub == 'r' or sub == '':
3481
3526
  continue
3527
+ if sub == 'o':
3528
+ # Overwrite last exported style file
3529
+ if not last_style_path:
3530
+ print("No previous export found.")
3531
+ continue
3532
+ if not os.path.exists(last_style_path):
3533
+ print(f"Previous export file not found: {last_style_path}")
3534
+ continue
3535
+ yn = input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
3536
+ if yn != 'y':
3537
+ continue
3538
+ # Determine export type from existing file and rebuild config
3539
+ try:
3540
+ with open(last_style_path, 'r', encoding='utf-8') as f:
3541
+ old_cfg = json.load(f)
3542
+ old_kind = old_cfg.get('kind', '')
3543
+ # Need to rebuild the full config - this requires the same logic as 'e' command
3544
+ # For simplicity, redirect user to use 'e' for now, or we could duplicate the config building code
3545
+ print("To overwrite with current style, please use 'e' to export fresh.")
3546
+ print("The 'o' option will be enhanced in a future update to rebuild config automatically.")
3547
+ continue
3548
+ except Exception as e:
3549
+ print(f"Error reading previous export: {e}")
3550
+ continue
3482
3551
  if sub == 'e':
3483
3552
  # Ask for ps or psg
3484
3553
  print("Export options:")
@@ -3598,6 +3667,23 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3598
3667
  'x': getattr(ec_ax.xaxis, 'labelpad', None),
3599
3668
  'y': getattr(ec_ax.yaxis, 'labelpad', None),
3600
3669
  }
3670
+ # Capture title offsets
3671
+ op_title_offsets = {
3672
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
3673
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
3674
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
3675
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
3676
+ 'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
3677
+ 'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
3678
+ }
3679
+ ec_title_offsets = {
3680
+ 'top_y': float(getattr(ec_ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
3681
+ 'top_x': float(getattr(ec_ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
3682
+ 'bottom_y': float(getattr(ec_ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
3683
+ 'left_x': float(getattr(ec_ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
3684
+ 'right_x': float(getattr(ec_ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
3685
+ 'right_y': float(getattr(ec_ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
3686
+ }
3601
3687
 
3602
3688
  if exp_choice == 'ps':
3603
3689
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
@@ -3607,8 +3693,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3607
3693
  'version': 2,
3608
3694
  'figure': {'canvas_size': [fig_w, fig_h], 'cb_visible': cb_visible, 'cb_label_mode': cb_label_mode},
3609
3695
  'geometry': {'op_w_in': ax_w_in, 'op_h_in': ax_h_in, 'ec_w_in': ec_w_in, 'cb_h_offset': float(cb_h_offset), 'ec_h_offset': float(ec_h_offset) if ec_h_offset is not None else None},
3610
- 'operando': {'cmap': cmap_name, 'wasd_state': op_wasd_state, 'spines': op_spines, 'ticks': {'widths': op_ticks}, 'y_reversed': op_reversed, 'intensity_range': intensity_range, 'labelpads': op_labelpads},
3611
- 'ec': {'wasd_state': ec_wasd_state, 'spines': ec_spines, 'ticks': {'widths': ec_ticks}, 'curve': ec_curve, 'y_reversed': ec_reversed, 'y_mode': ec_y_mode, 'ion_params': ion_params, 'visible': ec_visible, 'labelpads': ec_labelpads},
3696
+ 'operando': {'cmap': cmap_name, 'wasd_state': op_wasd_state, 'spines': op_spines, 'ticks': {'widths': op_ticks}, 'y_reversed': op_reversed, 'intensity_range': intensity_range, 'labelpads': op_labelpads, 'title_offsets': op_title_offsets},
3697
+ 'ec': {'wasd_state': ec_wasd_state, 'spines': ec_spines, 'ticks': {'widths': ec_ticks}, 'curve': ec_curve, 'y_reversed': ec_reversed, 'y_mode': ec_y_mode, 'ion_params': ion_params, 'visible': ec_visible, 'labelpads': ec_labelpads, 'title_offsets': ec_title_offsets},
3612
3698
  'font': {'family': fam, 'size': fsize},
3613
3699
  'colorbar': {'label': cb_label_text, 'mode': cb_label_mode, 'visible': cb_visible},
3614
3700
  }
@@ -3621,8 +3707,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3621
3707
  'version': 2,
3622
3708
  'figure': {'canvas_size': [fig_w, fig_h], 'cb_visible': cb_visible, 'cb_label_mode': cb_label_mode},
3623
3709
  'geometry': {'op_w_in': ax_w_in, 'op_h_in': ax_h_in, 'ec_w_in': ec_w_in, 'cb_h_offset': float(cb_h_offset), 'ec_h_offset': float(ec_h_offset) if ec_h_offset is not None else None},
3624
- 'operando': {'cmap': cmap_name, 'wasd_state': op_wasd_state, 'spines': op_spines, 'ticks': {'widths': op_ticks}, 'y_reversed': op_reversed, 'intensity_range': intensity_range, 'labelpads': op_labelpads},
3625
- 'ec': {'wasd_state': ec_wasd_state, 'spines': ec_spines, 'ticks': {'widths': ec_ticks}, 'curve': ec_curve, 'y_reversed': ec_reversed, 'y_mode': ec_y_mode, 'ion_params': ion_params, 'visible': ec_visible, 'labelpads': ec_labelpads},
3710
+ 'operando': {'cmap': cmap_name, 'wasd_state': op_wasd_state, 'spines': op_spines, 'ticks': {'widths': op_ticks}, 'y_reversed': op_reversed, 'intensity_range': intensity_range, 'labelpads': op_labelpads, 'title_offsets': op_title_offsets},
3711
+ 'ec': {'wasd_state': ec_wasd_state, 'spines': ec_spines, 'ticks': {'widths': ec_ticks}, 'curve': ec_curve, 'y_reversed': ec_reversed, 'y_mode': ec_y_mode, 'ion_params': ion_params, 'visible': ec_visible, 'labelpads': ec_labelpads, 'title_offsets': ec_title_offsets},
3626
3712
  'font': {'family': fam, 'size': fsize},
3627
3713
  'axes_geometry': _get_geometry_snapshot(ax, ec_ax),
3628
3714
  'colorbar': {'label': cb_label_text, 'mode': cb_label_mode, 'visible': cb_visible},
@@ -4223,6 +4309,33 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4223
4309
  except Exception:
4224
4310
  pass
4225
4311
 
4312
+ # Restore title offsets BEFORE applying labelpads
4313
+ if version >= 2:
4314
+ try:
4315
+ op_offsets = op.get('title_offsets', {})
4316
+ if op_offsets:
4317
+ ax._top_xlabel_manual_offset_y_pts = float(op_offsets.get('top_y', 0.0) or 0.0)
4318
+ ax._top_xlabel_manual_offset_x_pts = float(op_offsets.get('top_x', 0.0) or 0.0)
4319
+ ax._bottom_xlabel_manual_offset_y_pts = float(op_offsets.get('bottom_y', 0.0) or 0.0)
4320
+ ax._left_ylabel_manual_offset_x_pts = float(op_offsets.get('left_x', 0.0) or 0.0)
4321
+ ax._right_ylabel_manual_offset_x_pts = float(op_offsets.get('right_x', 0.0) or 0.0)
4322
+ ax._right_ylabel_manual_offset_y_pts = float(op_offsets.get('right_y', 0.0) or 0.0)
4323
+ except Exception as e:
4324
+ print(f"Warning: Could not apply operando title offsets: {e}")
4325
+
4326
+ try:
4327
+ ec_cfg = cfg.get('ec', {})
4328
+ ec_offsets = ec_cfg.get('title_offsets', {})
4329
+ if ec_offsets and ec_ax is not None:
4330
+ ec_ax._top_xlabel_manual_offset_y_pts = float(ec_offsets.get('top_y', 0.0) or 0.0)
4331
+ ec_ax._top_xlabel_manual_offset_x_pts = float(ec_offsets.get('top_x', 0.0) or 0.0)
4332
+ ec_ax._bottom_xlabel_manual_offset_y_pts = float(ec_offsets.get('bottom_y', 0.0) or 0.0)
4333
+ ec_ax._left_ylabel_manual_offset_x_pts = float(ec_offsets.get('left_x', 0.0) or 0.0)
4334
+ ec_ax._right_ylabel_manual_offset_x_pts = float(ec_offsets.get('right_x', 0.0) or 0.0)
4335
+ ec_ax._right_ylabel_manual_offset_y_pts = float(ec_offsets.get('right_y', 0.0) or 0.0)
4336
+ except Exception as e:
4337
+ print(f"Warning: Could not apply EC title offsets: {e}")
4338
+
4226
4339
  # Apply labelpads (title positioning) - preserve current if not in config
4227
4340
  if version >= 2:
4228
4341
  try:
@@ -4266,6 +4379,44 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4266
4379
  except Exception as e:
4267
4380
  print(f"Warning: Could not apply EC labelpads: {e}")
4268
4381
 
4382
+ # Reposition titles to apply offsets (after labelpads are set)
4383
+ try:
4384
+ from .ui import position_top_xlabel as _ui_position_top_xlabel, position_bottom_xlabel as _ui_position_bottom_xlabel, position_left_ylabel as _ui_position_left_ylabel, position_right_ylabel as _ui_position_right_ylabel
4385
+ # Build tick_state for operando pane
4386
+ op_ts = getattr(ax, '_saved_tick_state', {})
4387
+ op_tick_state = {
4388
+ 't_ticks': bool(op_ts.get('t_ticks', op_ts.get('tx', False))),
4389
+ 't_labels': bool(op_ts.get('t_labels', op_ts.get('tx', False))),
4390
+ 'b_ticks': bool(op_ts.get('b_ticks', op_ts.get('bx', True))),
4391
+ 'b_labels': bool(op_ts.get('b_labels', op_ts.get('bx', True))),
4392
+ 'l_ticks': bool(op_ts.get('l_ticks', op_ts.get('ly', True))),
4393
+ 'l_labels': bool(op_ts.get('l_labels', op_ts.get('ly', True))),
4394
+ 'r_ticks': bool(op_ts.get('r_ticks', op_ts.get('ry', False))),
4395
+ 'r_labels': bool(op_ts.get('r_labels', op_ts.get('ry', False))),
4396
+ }
4397
+ _ui_position_top_xlabel(ax, fig, op_tick_state)
4398
+ _ui_position_bottom_xlabel(ax, fig, op_tick_state)
4399
+ _ui_position_left_ylabel(ax, fig, op_tick_state)
4400
+ _ui_position_right_ylabel(ax, fig, op_tick_state)
4401
+ if ec_ax is not None:
4402
+ ec_ts = getattr(ec_ax, '_saved_tick_state', {})
4403
+ ec_tick_state = {
4404
+ 't_ticks': bool(ec_ts.get('t_ticks', ec_ts.get('tx', False))),
4405
+ 't_labels': bool(ec_ts.get('t_labels', ec_ts.get('tx', False))),
4406
+ 'b_ticks': bool(ec_ts.get('b_ticks', ec_ts.get('bx', True))),
4407
+ 'b_labels': bool(ec_ts.get('b_labels', ec_ts.get('bx', True))),
4408
+ 'l_ticks': bool(ec_ts.get('l_ticks', ec_ts.get('ly', True))),
4409
+ 'l_labels': bool(ec_ts.get('l_labels', ec_ts.get('ly', True))),
4410
+ 'r_ticks': bool(ec_ts.get('r_ticks', ec_ts.get('ry', False))),
4411
+ 'r_labels': bool(ec_ts.get('r_labels', ec_ts.get('ry', False))),
4412
+ }
4413
+ _ui_position_top_xlabel(ec_ax, fig, ec_tick_state)
4414
+ _ui_position_bottom_xlabel(ec_ax, fig, ec_tick_state)
4415
+ _ui_position_left_ylabel(ec_ax, fig, ec_tick_state)
4416
+ _ui_position_right_ylabel(ec_ax, fig, ec_tick_state)
4417
+ except Exception as e:
4418
+ print(f"Warning: Could not reposition titles: {e}")
4419
+
4269
4420
  # Final redraw
4270
4421
  try:
4271
4422
  fig.canvas.draw()
@@ -4314,7 +4465,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4314
4465
  print("Rename Operando Axes: x=rename X label, y=rename Y label, q=back")
4315
4466
  print("Tip: Use LaTeX/mathtext for special characters:")
4316
4467
  print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
4317
- print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4468
+ print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
4318
4469
  while True:
4319
4470
  sub = input("or> ").strip().lower()
4320
4471
  if not sub:
batplot/session.py CHANGED
@@ -701,6 +701,16 @@ def dump_operando_session(
701
701
  ec_wasd_state = _capture_wasd_state(ec_ax)
702
702
  ec_spines, ec_ticks = _capture_spine_tick_widths(ec_ax)
703
703
 
704
+ # Capture EC title offsets
705
+ ec_title_offsets = {
706
+ 'top_y': float(getattr(ec_ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
707
+ 'top_x': float(getattr(ec_ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
708
+ 'bottom_y': float(getattr(ec_ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
709
+ 'left_x': float(getattr(ec_ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
710
+ 'right_x': float(getattr(ec_ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
711
+ 'right_y': float(getattr(ec_ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
712
+ }
713
+
704
714
  ec_state = {
705
715
  'time_h': time_h,
706
716
  'volt_v': volt_v,
@@ -716,6 +726,7 @@ def dump_operando_session(
716
726
  'wasd_state': ec_wasd_state,
717
727
  'spines': ec_spines,
718
728
  'ticks': {'widths': ec_ticks},
729
+ 'title_offsets': ec_title_offsets,
719
730
  'stored_ylabel': getattr(ec_ax, '_stored_ylabel', None), # Save hidden ylabel text
720
731
  'visible': bool(ec_ax.get_visible()),
721
732
  }
@@ -750,6 +761,7 @@ def dump_operando_session(
750
761
  'wasd_state': op_wasd_state,
751
762
  'spines': op_spines,
752
763
  'ticks': {'widths': op_ticks},
764
+ 'title_offsets': op_title_offsets,
753
765
  'stored_ylabel': getattr(ax, '_stored_ylabel', None), # Save hidden ylabel text
754
766
  },
755
767
  'colorbar': {
@@ -939,6 +951,19 @@ def load_operando_session(filename: str):
939
951
  stored_ylabel = op.get('stored_ylabel')
940
952
  if stored_ylabel is not None:
941
953
  setattr(ax, '_stored_ylabel', stored_ylabel)
954
+
955
+ # Restore operando title offsets
956
+ try:
957
+ op_title_offsets = op.get('title_offsets', {})
958
+ if op_title_offsets:
959
+ ax._top_xlabel_manual_offset_y_pts = float(op_title_offsets.get('top_y', 0.0) or 0.0)
960
+ ax._top_xlabel_manual_offset_x_pts = float(op_title_offsets.get('top_x', 0.0) or 0.0)
961
+ ax._bottom_xlabel_manual_offset_y_pts = float(op_title_offsets.get('bottom_y', 0.0) or 0.0)
962
+ ax._left_ylabel_manual_offset_x_pts = float(op_title_offsets.get('left_x', 0.0) or 0.0)
963
+ ax._right_ylabel_manual_offset_x_pts = float(op_title_offsets.get('right_x', 0.0) or 0.0)
964
+ ax._right_ylabel_manual_offset_y_pts = float(op_title_offsets.get('right_y', 0.0) or 0.0)
965
+ except Exception:
966
+ pass
942
967
 
943
968
  # Apply operando spines
944
969
  op_spines = op.get('spines', {})
@@ -1189,6 +1214,19 @@ def load_operando_session(filename: str):
1189
1214
  if stored_ylabel is not None:
1190
1215
  setattr(ec_ax, '_stored_ylabel', stored_ylabel)
1191
1216
 
1217
+ # Restore EC title offsets
1218
+ try:
1219
+ ec_title_offsets = ec.get('title_offsets', {})
1220
+ if ec_title_offsets:
1221
+ ec_ax._top_xlabel_manual_offset_y_pts = float(ec_title_offsets.get('top_y', 0.0) or 0.0)
1222
+ ec_ax._top_xlabel_manual_offset_x_pts = float(ec_title_offsets.get('top_x', 0.0) or 0.0)
1223
+ ec_ax._bottom_xlabel_manual_offset_y_pts = float(ec_title_offsets.get('bottom_y', 0.0) or 0.0)
1224
+ ec_ax._left_ylabel_manual_offset_x_pts = float(ec_title_offsets.get('left_x', 0.0) or 0.0)
1225
+ ec_ax._right_ylabel_manual_offset_x_pts = float(ec_title_offsets.get('right_x', 0.0) or 0.0)
1226
+ ec_ax._right_ylabel_manual_offset_y_pts = float(ec_title_offsets.get('right_y', 0.0) or 0.0)
1227
+ except Exception:
1228
+ pass
1229
+
1192
1230
  # Apply EC spines (WASD state already applied above)
1193
1231
  if version >= 2:
1194
1232
  # Apply EC spines
@@ -1427,6 +1465,15 @@ def dump_ec_session(
1427
1465
  'top_x': bool(getattr(ax, '_top_xlabel_on', False)),
1428
1466
  'right_y': bool(getattr(ax, '_right_ylabel_on', False)),
1429
1467
  }
1468
+ # Title offsets
1469
+ title_offsets = {
1470
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1471
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
1472
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1473
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1474
+ 'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1475
+ 'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
1476
+ }
1430
1477
  # Subplot margins
1431
1478
  sp = fig.subplotpars
1432
1479
  subplot_margins = {
@@ -1545,6 +1592,7 @@ def dump_ec_session(
1545
1592
  'tick_direction': tick_direction,
1546
1593
  'spines': spines_state,
1547
1594
  'titles': titles,
1595
+ 'title_offsets': title_offsets,
1548
1596
  'mode': getattr(ax, '_is_dqdv_mode', None), # Store dQdV mode flag
1549
1597
  'rotation_angle': getattr(fig, '_ec_rotation_angle', 0), # Store rotation angle
1550
1598
  'source_paths': list(getattr(fig, '_bp_source_paths', []) or []),
@@ -1982,6 +2030,19 @@ def load_ec_session(filename: str):
1982
2030
  ax.set_ylabel('')
1983
2031
  ax.yaxis.label.set_visible(False)
1984
2032
 
2033
+ # Restore title offsets BEFORE positioning titles
2034
+ try:
2035
+ title_offsets = sess.get('title_offsets', {})
2036
+ if title_offsets:
2037
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
2038
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
2039
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
2040
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
2041
+ ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
2042
+ ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
2043
+ except Exception:
2044
+ pass
2045
+
1985
2046
  # Duplicate titles
1986
2047
  try:
1987
2048
  titles = sess.get('titles', {})
@@ -2262,6 +2323,15 @@ def dump_cpc_session(
2262
2323
  'top_xlabel': getattr(ax, '_stored_top_xlabel', ''),
2263
2324
  'right_ylabel': getattr(ax2, '_stored_ylabel', ax2.get_ylabel()),
2264
2325
  }
2326
+ # Title offsets
2327
+ title_offsets = {
2328
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
2329
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
2330
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
2331
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
2332
+ 'right_x': float(getattr(ax2, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
2333
+ 'right_y': float(getattr(ax2, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
2334
+ }
2265
2335
 
2266
2336
  meta = {
2267
2337
  'kind': 'cpc',
@@ -2324,6 +2394,7 @@ def dump_cpc_session(
2324
2394
  'wasd_state': wasd_state,
2325
2395
  'tick_widths': tick_widths,
2326
2396
  'stored_titles': stored_titles,
2397
+ 'title_offsets': title_offsets,
2327
2398
  'font': {
2328
2399
  'size': plt.rcParams.get('font.size'),
2329
2400
  'chain': list(plt.rcParams.get('font.sans-serif', [])),
@@ -2337,6 +2408,30 @@ def dump_cpc_session(
2337
2408
  if file_data and isinstance(file_data, list) and len(file_data) > 0:
2338
2409
  multi_files = []
2339
2410
  for f in file_data:
2411
+ def _marker_of(sc, default_val):
2412
+ try:
2413
+ m = getattr(sc, 'get_marker', lambda: default_val)()
2414
+ if m is None:
2415
+ return default_val
2416
+ return m
2417
+ except Exception:
2418
+ return default_val
2419
+ def _alpha_of(sc, default_val=None):
2420
+ try:
2421
+ a = sc.get_alpha()
2422
+ return float(a) if a is not None else default_val
2423
+ except Exception:
2424
+ return default_val
2425
+ def _visible_of(sc, default_val=True):
2426
+ try:
2427
+ return bool(sc.get_visible())
2428
+ except Exception:
2429
+ return default_val
2430
+ def _label_of(sc, default_val=""):
2431
+ try:
2432
+ return sc.get_label() or default_val
2433
+ except Exception:
2434
+ return default_val
2340
2435
  file_info = {
2341
2436
  'filename': f.get('filename', 'unknown'),
2342
2437
  'visible': f.get('visible', True),
@@ -2344,16 +2439,31 @@ def dump_cpc_session(
2344
2439
  'x': _np.array(_scatter_xy(f.get('sc_charge', sc_charge))[0]),
2345
2440
  'y': _np.array(_scatter_xy(f.get('sc_charge', sc_charge))[1]),
2346
2441
  'color': _color_of(f.get('sc_charge')),
2442
+ 'size': _size_of(f.get('sc_charge'), 32.0),
2443
+ 'alpha': _alpha_of(f.get('sc_charge')),
2444
+ 'marker': _marker_of(f.get('sc_charge'), 'o'),
2445
+ 'label': _label_of(f.get('sc_charge'), 'Charge capacity'),
2446
+ 'visible': _visible_of(f.get('sc_charge')),
2347
2447
  },
2348
2448
  'discharge': {
2349
2449
  'x': _np.array(_scatter_xy(f.get('sc_discharge', sc_discharge))[0]),
2350
2450
  'y': _np.array(_scatter_xy(f.get('sc_discharge', sc_discharge))[1]),
2351
2451
  'color': _color_of(f.get('sc_discharge')),
2452
+ 'size': _size_of(f.get('sc_discharge'), 32.0),
2453
+ 'alpha': _alpha_of(f.get('sc_discharge')),
2454
+ 'marker': _marker_of(f.get('sc_discharge'), 's'),
2455
+ 'label': _label_of(f.get('sc_discharge'), 'Discharge capacity'),
2456
+ 'visible': _visible_of(f.get('sc_discharge')),
2352
2457
  },
2353
2458
  'efficiency': {
2354
2459
  'x': _np.array(_scatter_xy(f.get('sc_eff', sc_eff))[0]),
2355
2460
  'y': _np.array(_scatter_xy(f.get('sc_eff', sc_eff))[1]),
2356
2461
  'color': _color_of(f.get('sc_eff')),
2462
+ 'size': _size_of(f.get('sc_eff'), 40.0),
2463
+ 'alpha': _alpha_of(f.get('sc_eff')),
2464
+ 'marker': _marker_of(f.get('sc_eff'), '^'),
2465
+ 'label': _label_of(f.get('sc_eff'), 'Coulombic efficiency'),
2466
+ 'visible': _visible_of(f.get('sc_eff')),
2357
2467
  }
2358
2468
  }
2359
2469
  multi_files.append(file_info)
@@ -2374,9 +2484,9 @@ def dump_cpc_session(
2374
2484
 
2375
2485
 
2376
2486
  def load_cpc_session(filename: str):
2377
- """Load a CPC session and reconstruct fig, axes, and scatter artists.
2487
+ """Load a CPC session and reconstruct fig, axes, scatter artists, and file_data.
2378
2488
 
2379
- Returns: (fig, ax, ax2, sc_charge, sc_discharge, sc_eff)
2489
+ Returns: (fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2380
2490
  """
2381
2491
  try:
2382
2492
  with open(filename, 'rb') as f:
@@ -2463,12 +2573,47 @@ def load_cpc_session(filename: str):
2463
2573
  except Exception:
2464
2574
  pass
2465
2575
  return sc
2466
- sc_charge = _mk_sc(ax, ch, 'o')
2467
- sc_discharge = _mk_sc(ax, dh, 'o')
2468
- # efficiency on ax2 with triangles
2469
- if 'marker' not in ef:
2470
- ef['marker'] = '^'
2471
- sc_eff = _mk_sc(ax2, ef, '^')
2576
+ # If multi_files exist, rebuild all files and pick the first as primary
2577
+ multi_files = sess.get('multi_files')
2578
+ file_data = []
2579
+ if multi_files and isinstance(multi_files, list) and len(multi_files) > 0:
2580
+ for idx, finfo in enumerate(multi_files):
2581
+ ch_info = finfo.get('charge', {})
2582
+ dh_info = finfo.get('discharge', {})
2583
+ ef_info = finfo.get('efficiency', {})
2584
+ sc_ch = _mk_sc(ax, ch_info, ch_info.get('marker', 'o') or 'o')
2585
+ sc_dh = _mk_sc(ax, dh_info, dh_info.get('marker', 's') or 's')
2586
+ eff_marker = ef_info.get('marker', '^') or '^'
2587
+ sc_ef = _mk_sc(ax2, ef_info, eff_marker)
2588
+ # Respect overall file visibility
2589
+ try:
2590
+ vis_file = bool(finfo.get('visible', True))
2591
+ except Exception:
2592
+ vis_file = True
2593
+ for sc_tmp in (sc_ch, sc_dh, sc_ef):
2594
+ try:
2595
+ sc_tmp.set_visible(sc_tmp.get_visible() and vis_file)
2596
+ except Exception:
2597
+ pass
2598
+ file_data.append({
2599
+ 'filename': finfo.get('filename', f'File {idx+1}'),
2600
+ 'visible': vis_file,
2601
+ 'sc_charge': sc_ch,
2602
+ 'sc_discharge': sc_dh,
2603
+ 'sc_eff': sc_ef,
2604
+ })
2605
+ # Use the first file as primary artists for interactive menu
2606
+ sc_charge = file_data[0]['sc_charge']
2607
+ sc_discharge = file_data[0]['sc_discharge']
2608
+ sc_eff = file_data[0]['sc_eff']
2609
+ else:
2610
+ # No multi-file info: fall back to single-file series
2611
+ sc_charge = _mk_sc(ax, ch, 'o')
2612
+ sc_discharge = _mk_sc(ax, dh, 's')
2613
+ if 'marker' not in ef:
2614
+ ef['marker'] = '^'
2615
+ sc_eff = _mk_sc(ax2, ef, '^')
2616
+ file_data = None
2472
2617
 
2473
2618
  # Restore spines state (version 2+)
2474
2619
  try:
@@ -2622,6 +2767,16 @@ def load_cpc_session(filename: str):
2622
2767
  ax2.tick_params(axis='y',
2623
2768
  right=wasd_state['right'].get('ticks', True),
2624
2769
  labelright=wasd_state['right'].get('labels', True))
2770
+ # Axis title visibility
2771
+ try:
2772
+ if 'bottom' in wasd_state:
2773
+ ax.xaxis.label.set_visible(bool(wasd_state['bottom'].get('title', True)))
2774
+ if 'left' in wasd_state:
2775
+ ax.yaxis.label.set_visible(bool(wasd_state['left'].get('title', True)))
2776
+ if 'right' in wasd_state:
2777
+ ax2.yaxis.label.set_visible(bool(wasd_state['right'].get('title', True)))
2778
+ except Exception:
2779
+ pass
2625
2780
 
2626
2781
  # Minor ticks
2627
2782
  if wasd_state.get('top', {}).get('minor') or wasd_state.get('bottom', {}).get('minor'):
@@ -2660,6 +2815,19 @@ def load_cpc_session(filename: str):
2660
2815
  except Exception:
2661
2816
  pass
2662
2817
 
2818
+ # Restore title offsets BEFORE restoring titles
2819
+ try:
2820
+ title_offsets = sess.get('title_offsets', {})
2821
+ if title_offsets:
2822
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
2823
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
2824
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
2825
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
2826
+ ax2._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
2827
+ ax2._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
2828
+ except Exception:
2829
+ pass
2830
+
2663
2831
  # Restore stored title texts (version 2+)
2664
2832
  try:
2665
2833
  stored_titles = sess.get('stored_titles', {})
@@ -2683,26 +2851,46 @@ def load_cpc_session(filename: str):
2683
2851
  try:
2684
2852
  handles1, labels1 = ax.get_legend_handles_labels()
2685
2853
  handles2, labels2 = ax2.get_legend_handles_labels()
2686
- if handles1 or handles2:
2687
- leg_meta = sess.get('legend', {})
2688
- xy_in = leg_meta.get('xy_in')
2854
+ # Filter visible handles only
2855
+ H, L = [], []
2856
+ for h, l in list(zip(handles1, labels1)) + list(zip(handles2, labels2)):
2857
+ try:
2858
+ if hasattr(h, 'get_visible') and not h.get_visible():
2859
+ continue
2860
+ except Exception:
2861
+ pass
2862
+ H.append(h); L.append(l)
2863
+ leg_meta = sess.get('legend', {})
2864
+ xy_in = leg_meta.get('xy_in')
2865
+ vis = bool(leg_meta.get('visible', True))
2866
+ if H and vis:
2689
2867
  if xy_in is not None:
2690
2868
  fw, fh = fig.get_size_inches()
2691
2869
  fx = 0.5 + float(xy_in[0]) / float(fw)
2692
2870
  fy = 0.5 + float(xy_in[1]) / float(fh)
2693
- ax.legend(handles1 + handles2, labels1 + labels2, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
2871
+ leg = ax.legend(H, L, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure, borderaxespad=1.0)
2694
2872
  # persist inches on fig for interactive menu
2695
2873
  try:
2696
2874
  fig._cpc_legend_xy_in = (float(xy_in[0]), float(xy_in[1]))
2697
2875
  except Exception:
2698
2876
  pass
2699
2877
  else:
2700
- ax.legend(handles1 + handles2, labels1 + labels2, loc='best', borderaxespad=1.0)
2701
- # Apply visibility
2702
- vis = bool(leg_meta.get('visible', True))
2878
+ leg = ax.legend(H, L, loc='best', borderaxespad=1.0)
2879
+ # Always hide legend frame to match interactive export behavior
2880
+ try:
2881
+ if leg is not None:
2882
+ leg.set_frame_on(False)
2883
+ except Exception:
2884
+ pass
2885
+ else:
2886
+ try:
2887
+ fig._cpc_legend_xy_in = (float(xy_in[0]), float(xy_in[1])) if xy_in is not None else None
2888
+ except Exception:
2889
+ pass
2890
+ # ensure legend hidden
2703
2891
  leg = ax.get_legend()
2704
2892
  if leg is not None:
2705
- leg.set_visible(vis)
2893
+ leg.set_visible(False)
2706
2894
  except Exception:
2707
2895
  pass
2708
2896
  try:
@@ -2712,7 +2900,7 @@ def load_cpc_session(filename: str):
2712
2900
  fig.canvas.draw_idle()
2713
2901
  except Exception:
2714
2902
  pass
2715
- return fig, ax, ax2, sc_charge, sc_discharge, sc_eff
2903
+ return fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data
2716
2904
  except Exception as e:
2717
2905
  import traceback
2718
2906
  print(f"Error loading CPC session: {e}")