batplot 1.7.22__py3-none-any.whl → 1.7.24__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.

Potentially problematic release.


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

@@ -1050,6 +1050,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1050
1050
  # Horizontal offsets (relative to canvas center, in inches)
1051
1051
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
1052
1052
  ec_h_offset = getattr(ec_ax, '_ec_h_offset_in', 0.0) if ec_ax is not None else None
1053
+ # Colorbar tick/label positions (left/right)
1054
+ cb_ticks_left = True
1055
+ cb_label_left = True
1056
+ try:
1057
+ cb_ticks_left = any(getattr(tick, 'tick1line', None) and tick.tick1line.get_visible() for tick in cbar.ax.yaxis.get_major_ticks())
1058
+ # label position is stored on axis; capture current setting
1059
+ cb_label_left = (cbar.ax.yaxis.get_label_position() == 'left')
1060
+ except Exception:
1061
+ pass
1053
1062
  # Label pads (save current labelpad values to restore later)
1054
1063
  op_labelpads = {
1055
1064
  'x': getattr(ax.xaxis, 'labelpad', None),
@@ -1084,6 +1093,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1084
1093
  'ec_visible': ec_visible,
1085
1094
  'cb_h_offset': float(cb_h_offset),
1086
1095
  'ec_h_offset': float(ec_h_offset) if ec_h_offset is not None else None,
1096
+ 'cb_ticks_left': cb_ticks_left,
1097
+ 'cb_label_left': cb_label_left,
1087
1098
  'op_labelpads': dict(op_labelpads),
1088
1099
  'ec_labelpads': dict(ec_labelpads) if ec_labelpads is not None else None,
1089
1100
  'op_title_offsets': {
@@ -1138,6 +1149,14 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1138
1149
  _apply_group_layout_inches(fig, ax, cbar.ax, ec_ax, ax_w_i, ax_h_i, cb_w_i, cb_gap_i, ec_gap_i, ec_w_i)
1139
1150
  except Exception:
1140
1151
  pass
1152
+ # Colorbar tick/label side
1153
+ try:
1154
+ cb_ticks_left = snap.get('cb_ticks_left', True)
1155
+ cb_label_left = snap.get('cb_label_left', True)
1156
+ cbar.ax.yaxis.set_ticks_position('left' if cb_ticks_left else 'right')
1157
+ cbar.ax.yaxis.set_label_position('left' if cb_label_left else 'right')
1158
+ except Exception:
1159
+ pass
1141
1160
  # Labels
1142
1161
  try:
1143
1162
  op_l = snap.get('op_labels', {})
@@ -1170,6 +1189,14 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1170
1189
  pass
1171
1190
  try:
1172
1191
  if snap.get('clim') is not None:
1192
+ # Detach built-in colorbar update to avoid artist removal errors; we redraw custom below.
1193
+ try:
1194
+ if hasattr(cbar, 'mappable'):
1195
+ cbar.mappable = None
1196
+ if hasattr(cbar, 'solids'):
1197
+ cbar.solids = None
1198
+ except Exception:
1199
+ pass
1173
1200
  lo, hi = snap['clim']; im.set_clim(float(lo), float(hi))
1174
1201
  except Exception:
1175
1202
  pass
@@ -1180,6 +1207,15 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1180
1207
  cbar.update_normal(im)
1181
1208
  except Exception:
1182
1209
  pass
1210
+ # Restore colorbar side (ticks/label) and redraw custom colorbar to keep position
1211
+ try:
1212
+ cb_ticks_left = snap.get('cb_ticks_left', True)
1213
+ cb_label_left = snap.get('cb_label_left', True)
1214
+ cbar.ax.yaxis.set_ticks_position('left' if cb_ticks_left else 'right')
1215
+ cbar.ax.yaxis.set_label_position('left' if cb_label_left else 'right')
1216
+ _update_custom_colorbar(cbar.ax, im)
1217
+ except Exception:
1218
+ pass
1183
1219
  # EC axes
1184
1220
  try:
1185
1221
  if ec_ax is not None:
@@ -1680,13 +1716,31 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1680
1716
  else:
1681
1717
  print(f" {i}: {fname}")
1682
1718
 
1683
- 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()
1684
1724
  if not fname or fname.lower() == 'q':
1685
1725
  print_menu(); continue
1686
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
1687
1741
  # Check if user selected a number
1688
- already_confirmed = False
1689
- if fname.isdigit() and files:
1742
+ elif fname.isdigit() and files:
1743
+ already_confirmed = False
1690
1744
  idx = int(fname)
1691
1745
  if 1 <= idx <= len(files):
1692
1746
  name = files[idx-1]
@@ -1746,6 +1800,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1746
1800
  else:
1747
1801
  fig.savefig(target, dpi=300)
1748
1802
  print(f"Exported figure to {target}")
1803
+ fig._last_figure_export_path = target
1749
1804
  except Exception as e:
1750
1805
  print(f"Export failed: {e}")
1751
1806
  print_menu(); continue
@@ -1949,10 +2004,28 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1949
2004
  print(f" {i}: {f} ({timestamp})")
1950
2005
  else:
1951
2006
  print(f" {i}: {f}")
1952
- 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): "
1953
2012
  choice = input(prompt).strip()
1954
2013
  if not choice or choice.lower() == 'q':
1955
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
1956
2029
  if choice.isdigit() and files:
1957
2030
  idx = int(choice)
1958
2031
  if 1 <= idx <= len(files):
@@ -1961,10 +2034,13 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1961
2034
  if yn != 'y':
1962
2035
  print_menu(); continue
1963
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
1964
2040
  else:
1965
2041
  print("Invalid number.")
1966
2042
  print_menu(); continue
1967
- else:
2043
+ if choice.lower() != 'o':
1968
2044
  name = choice
1969
2045
  root, ext = os.path.splitext(name)
1970
2046
  if ext == '':
@@ -1974,7 +2050,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1974
2050
  yn = input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
1975
2051
  if yn != 'y':
1976
2052
  print_menu(); continue
1977
- 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
1978
2055
  except Exception as e:
1979
2056
  print(f"Save failed: {e}")
1980
2057
  print_menu(); continue
@@ -2840,7 +2917,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2840
2917
  continue
2841
2918
  _snapshot("operando-xrange")
2842
2919
  ax.set_xlim(cur[0], new_upper)
2843
- _renormalize_to_visible()
2844
2920
  fig.canvas.draw_idle()
2845
2921
  print(f"Operando X range updated: {ax.get_xlim()[0]:.4g} {ax.get_xlim()[1]:.4g}")
2846
2922
  if line.lower() == 'w':
@@ -2860,7 +2936,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2860
2936
  continue
2861
2937
  _snapshot("operando-xrange")
2862
2938
  ax.set_xlim(new_lower, cur[1])
2863
- _renormalize_to_visible()
2864
2939
  fig.canvas.draw_idle()
2865
2940
  print(f"Operando X range updated: {ax.get_xlim()[0]:.4g} {ax.get_xlim()[1]:.4g}")
2866
2941
  if line.lower() == 's':
@@ -2877,7 +2952,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2877
2952
  orig_min = min(extent[0], extent[1])
2878
2953
  orig_max = max(extent[0], extent[1])
2879
2954
  ax.set_xlim(orig_min, orig_max)
2880
- _renormalize_to_visible()
2881
2955
  fig.canvas.draw_idle()
2882
2956
  print(f"Operando X range restored to original: {ax.get_xlim()[0]:.4g} {ax.get_xlim()[1]:.4g}")
2883
2957
  else:
@@ -2891,8 +2965,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2891
2965
  try:
2892
2966
  lo, hi = map(float, line.split())
2893
2967
  ax.set_xlim(lo, hi)
2894
- # Re-normalize intensity to visible region
2895
- _renormalize_to_visible()
2896
2968
  fig.canvas.draw_idle()
2897
2969
  except Exception as e:
2898
2970
  print(f"Invalid range: {e}")
@@ -2918,7 +2990,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2918
2990
  continue
2919
2991
  _snapshot("operando-yrange")
2920
2992
  ax.set_ylim(cur[0], new_upper)
2921
- _renormalize_to_visible()
2922
2993
  fig.canvas.draw_idle()
2923
2994
  print(f"Operando Y range updated: {ax.get_ylim()[0]:.4g} {ax.get_ylim()[1]:.4g}")
2924
2995
  if line.lower() == 'w':
@@ -2938,7 +3009,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2938
3009
  continue
2939
3010
  _snapshot("operando-yrange")
2940
3011
  ax.set_ylim(new_lower, cur[1])
2941
- _renormalize_to_visible()
2942
3012
  fig.canvas.draw_idle()
2943
3013
  print(f"Operando Y range updated: {ax.get_ylim()[0]:.4g} {ax.get_ylim()[1]:.4g}")
2944
3014
  if line.lower() == 's':
@@ -2955,7 +3025,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2955
3025
  orig_min = min(extent[2], extent[3])
2956
3026
  orig_max = max(extent[2], extent[3])
2957
3027
  ax.set_ylim(orig_min, orig_max)
2958
- _renormalize_to_visible()
2959
3028
  fig.canvas.draw_idle()
2960
3029
  print(f"Operando Y range restored to original: {ax.get_ylim()[0]:.4g} {ax.get_ylim()[1]:.4g}")
2961
3030
  else:
@@ -2969,8 +3038,6 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2969
3038
  try:
2970
3039
  lo, hi = map(float, line.split())
2971
3040
  ax.set_ylim(lo, hi)
2972
- # Re-normalize intensity to visible region
2973
- _renormalize_to_visible()
2974
3041
  fig.canvas.draw_idle()
2975
3042
  except Exception as e:
2976
3043
  print(f"Invalid range: {e}")
@@ -3437,6 +3504,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3437
3504
  else:
3438
3505
  print(f" {_i}: {fname}")
3439
3506
 
3507
+ last_style_path = getattr(fig, '_last_style_export_path', None)
3440
3508
  if ec_ax is None:
3441
3509
  print("\nNote: Style export (.bps/.bpsg) is only available in dual-pane mode (with EC file).")
3442
3510
  sub = input("Style submenu: (q=return, r=refresh): ").strip().lower()
@@ -3448,11 +3516,38 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3448
3516
  print("Unknown choice.")
3449
3517
  continue
3450
3518
  else:
3451
- 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()
3452
3523
  if sub == 'q':
3453
3524
  break
3454
3525
  if sub == 'r' or sub == '':
3455
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
3456
3551
  if sub == 'e':
3457
3552
  # Ask for ps or psg
3458
3553
  print("Export options:")
@@ -3572,6 +3667,23 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3572
3667
  'x': getattr(ec_ax.xaxis, 'labelpad', None),
3573
3668
  'y': getattr(ec_ax.yaxis, 'labelpad', None),
3574
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
+ }
3575
3687
 
3576
3688
  if exp_choice == 'ps':
3577
3689
  cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
@@ -3581,8 +3693,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3581
3693
  'version': 2,
3582
3694
  'figure': {'canvas_size': [fig_w, fig_h], 'cb_visible': cb_visible, 'cb_label_mode': cb_label_mode},
3583
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},
3584
- '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},
3585
- '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},
3586
3698
  'font': {'family': fam, 'size': fsize},
3587
3699
  'colorbar': {'label': cb_label_text, 'mode': cb_label_mode, 'visible': cb_visible},
3588
3700
  }
@@ -3595,8 +3707,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
3595
3707
  'version': 2,
3596
3708
  'figure': {'canvas_size': [fig_w, fig_h], 'cb_visible': cb_visible, 'cb_label_mode': cb_label_mode},
3597
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},
3598
- '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},
3599
- '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},
3600
3712
  'font': {'family': fam, 'size': fsize},
3601
3713
  'axes_geometry': _get_geometry_snapshot(ax, ec_ax),
3602
3714
  'colorbar': {'label': cb_label_text, 'mode': cb_label_mode, 'visible': cb_visible},
@@ -4197,6 +4309,33 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4197
4309
  except Exception:
4198
4310
  pass
4199
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
+
4200
4339
  # Apply labelpads (title positioning) - preserve current if not in config
4201
4340
  if version >= 2:
4202
4341
  try:
@@ -4240,6 +4379,44 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4240
4379
  except Exception as e:
4241
4380
  print(f"Warning: Could not apply EC labelpads: {e}")
4242
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
+
4243
4420
  # Final redraw
4244
4421
  try:
4245
4422
  fig.canvas.draw()
batplot/session.py CHANGED
@@ -38,6 +38,7 @@ import matplotlib.pyplot as plt
38
38
  import numpy as np
39
39
 
40
40
  from .utils import _confirm_overwrite
41
+ from .color_utils import ensure_colormap
41
42
 
42
43
 
43
44
  def _current_tick_width(axis_obj, which: str):
@@ -700,6 +701,16 @@ def dump_operando_session(
700
701
  ec_wasd_state = _capture_wasd_state(ec_ax)
701
702
  ec_spines, ec_ticks = _capture_spine_tick_widths(ec_ax)
702
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
+
703
714
  ec_state = {
704
715
  'time_h': time_h,
705
716
  'volt_v': volt_v,
@@ -715,6 +726,7 @@ def dump_operando_session(
715
726
  'wasd_state': ec_wasd_state,
716
727
  'spines': ec_spines,
717
728
  'ticks': {'widths': ec_ticks},
729
+ 'title_offsets': ec_title_offsets,
718
730
  'stored_ylabel': getattr(ec_ax, '_stored_ylabel', None), # Save hidden ylabel text
719
731
  'visible': bool(ec_ax.get_visible()),
720
732
  }
@@ -749,6 +761,7 @@ def dump_operando_session(
749
761
  'wasd_state': op_wasd_state,
750
762
  'spines': op_spines,
751
763
  'ticks': {'widths': op_ticks},
764
+ 'title_offsets': op_title_offsets,
752
765
  'stored_ylabel': getattr(ax, '_stored_ylabel', None), # Save hidden ylabel text
753
766
  },
754
767
  'colorbar': {
@@ -831,8 +844,14 @@ def load_operando_session(filename: str):
831
844
  op = sess['operando']
832
845
  arr = _ma.masked_invalid(op['array'])
833
846
  extent = tuple(op['extent']) if op['extent'] is not None else None
847
+ cmap_name = op.get('cmap') or 'viridis'
848
+ try:
849
+ if not ensure_colormap(cmap_name):
850
+ cmap_name = 'viridis'
851
+ except Exception:
852
+ cmap_name = 'viridis'
834
853
  im = ax.imshow(arr, aspect='auto', origin=op.get('origin', 'upper'), extent=extent,
835
- cmap=op.get('cmap') or 'viridis', interpolation=op.get('interpolation', 'nearest'))
854
+ cmap=cmap_name, interpolation=op.get('interpolation', 'nearest'))
836
855
  if op.get('clim'):
837
856
  try:
838
857
  im.set_clim(*op['clim'])
@@ -932,6 +951,19 @@ def load_operando_session(filename: str):
932
951
  stored_ylabel = op.get('stored_ylabel')
933
952
  if stored_ylabel is not None:
934
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
935
967
 
936
968
  # Apply operando spines
937
969
  op_spines = op.get('spines', {})
@@ -1182,6 +1214,19 @@ def load_operando_session(filename: str):
1182
1214
  if stored_ylabel is not None:
1183
1215
  setattr(ec_ax, '_stored_ylabel', stored_ylabel)
1184
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
+
1185
1230
  # Apply EC spines (WASD state already applied above)
1186
1231
  if version >= 2:
1187
1232
  # Apply EC spines
@@ -1420,6 +1465,15 @@ def dump_ec_session(
1420
1465
  'top_x': bool(getattr(ax, '_top_xlabel_on', False)),
1421
1466
  'right_y': bool(getattr(ax, '_right_ylabel_on', False)),
1422
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
+ }
1423
1477
  # Subplot margins
1424
1478
  sp = fig.subplotpars
1425
1479
  subplot_margins = {
@@ -1538,6 +1592,7 @@ def dump_ec_session(
1538
1592
  'tick_direction': tick_direction,
1539
1593
  'spines': spines_state,
1540
1594
  'titles': titles,
1595
+ 'title_offsets': title_offsets,
1541
1596
  'mode': getattr(ax, '_is_dqdv_mode', None), # Store dQdV mode flag
1542
1597
  'rotation_angle': getattr(fig, '_ec_rotation_angle', 0), # Store rotation angle
1543
1598
  'source_paths': list(getattr(fig, '_bp_source_paths', []) or []),
@@ -1975,6 +2030,19 @@ def load_ec_session(filename: str):
1975
2030
  ax.set_ylabel('')
1976
2031
  ax.yaxis.label.set_visible(False)
1977
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
+
1978
2046
  # Duplicate titles
1979
2047
  try:
1980
2048
  titles = sess.get('titles', {})
@@ -2255,6 +2323,15 @@ def dump_cpc_session(
2255
2323
  'top_xlabel': getattr(ax, '_stored_top_xlabel', ''),
2256
2324
  'right_ylabel': getattr(ax2, '_stored_ylabel', ax2.get_ylabel()),
2257
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
+ }
2258
2335
 
2259
2336
  meta = {
2260
2337
  'kind': 'cpc',
@@ -2317,6 +2394,7 @@ def dump_cpc_session(
2317
2394
  'wasd_state': wasd_state,
2318
2395
  'tick_widths': tick_widths,
2319
2396
  'stored_titles': stored_titles,
2397
+ 'title_offsets': title_offsets,
2320
2398
  'font': {
2321
2399
  'size': plt.rcParams.get('font.size'),
2322
2400
  'chain': list(plt.rcParams.get('font.sans-serif', [])),
@@ -2653,6 +2731,19 @@ def load_cpc_session(filename: str):
2653
2731
  except Exception:
2654
2732
  pass
2655
2733
 
2734
+ # Restore title offsets BEFORE restoring titles
2735
+ try:
2736
+ title_offsets = sess.get('title_offsets', {})
2737
+ if title_offsets:
2738
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
2739
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
2740
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
2741
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
2742
+ ax2._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
2743
+ ax2._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
2744
+ except Exception:
2745
+ pass
2746
+
2656
2747
  # Restore stored title texts (version 2+)
2657
2748
  try:
2658
2749
  stored_titles = sess.get('stored_titles', {})