batplot 1.8.4__py3-none-any.whl → 1.8.6__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.
batplot/modes.py CHANGED
@@ -151,7 +151,8 @@ def handle_cv_mode(args) -> int:
151
151
  # Configure fonts to match other modes (consistent across batplot)
152
152
  plt.rcParams.update({
153
153
  'font.family': 'sans-serif',
154
- 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
154
+ # Prefer DejaVu Sans first for full Unicode coverage (subscripts, etc.)
155
+ 'font.sans-serif': ['DejaVu Sans', 'Arial', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
155
156
  'mathtext.fontset': 'dejavusans',
156
157
  'font.size': 16
157
158
  })
@@ -280,8 +281,8 @@ def handle_cv_mode(args) -> int:
280
281
  ax.set_xlabel('Current (mA)', labelpad=8.0)
281
282
  ax.set_ylabel('Voltage (V)', labelpad=8.0)
282
283
  else:
283
- ax.set_xlabel('Voltage (V)', labelpad=8.0)
284
- ax.set_ylabel('Current (mA)', labelpad=8.0)
284
+ ax.set_xlabel('Voltage (V)', labelpad=8.0)
285
+ ax.set_ylabel('Current (mA)', labelpad=8.0)
285
286
  legend = ax.legend(title='Cycle')
286
287
  legend.get_title().set_fontsize('medium')
287
288
  # Adjust margins to prevent label clipping
@@ -642,8 +643,8 @@ def handle_gc_mode(args) -> int:
642
643
  ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
643
644
  linewidth=2.0, label=str(cyc), alpha=0.8)
644
645
  else:
645
- ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
646
- linewidth=2.0, label=str(cyc), alpha=0.8)
646
+ ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
647
+ linewidth=2.0, label=str(cyc), alpha=0.8)
647
648
  else:
648
649
  ln_c = None
649
650
  mask_d = (cyc_int == cyc) & discharge_mask
@@ -656,8 +657,8 @@ def handle_gc_mode(args) -> int:
656
657
  ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
657
658
  linewidth=2.0, label=lbl, alpha=0.8)
658
659
  else:
659
- ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
660
- linewidth=2.0, label=lbl, alpha=0.8)
660
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
661
+ linewidth=2.0, label=lbl, alpha=0.8)
661
662
  else:
662
663
  ln_d = None
663
664
  cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
@@ -690,8 +691,8 @@ def handle_gc_mode(args) -> int:
690
691
  ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
691
692
  linewidth=2.0, label=lbl, alpha=0.8)
692
693
  else:
693
- ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
694
- linewidth=2.0, label=lbl, alpha=0.8)
694
+ ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
695
+ linewidth=2.0, label=lbl, alpha=0.8)
695
696
  cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
696
697
 
697
698
  # Swap x and y if --ro flag is set
@@ -699,8 +700,8 @@ def handle_gc_mode(args) -> int:
699
700
  ax.set_xlabel('Voltage (V)', labelpad=8.0)
700
701
  ax.set_ylabel(x_label_gc, labelpad=8.0)
701
702
  else:
702
- ax.set_xlabel(x_label_gc, labelpad=8.0)
703
- ax.set_ylabel('Voltage (V)', labelpad=8.0)
703
+ ax.set_xlabel(x_label_gc, labelpad=8.0)
704
+ ax.set_ylabel('Voltage (V)', labelpad=8.0)
704
705
  legend = ax.legend(title='Cycle')
705
706
  legend.get_title().set_fontsize('medium')
706
707
  fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
batplot/operando.py CHANGED
@@ -296,6 +296,28 @@ def plot_operando_folder(folder: str, args) -> Tuple[plt.Figure, plt.Axes, Dict[
296
296
  # Result shape: (n_scans, n_x_points)
297
297
  # Example: 50 scans × 1000 points = (50, 1000) array
298
298
  Z = np.vstack(stack) # shape (n_scans, n_x)
299
+
300
+ # STEP 5.5: Apply first derivative if --1d or --2d flag is set
301
+ # This calculates dy/dx for each scan using np.gradient
302
+ if getattr(args, 'derivative_1d', False) or getattr(args, 'derivative_2d', False):
303
+ print("[operando] Applying first derivative (dy/dx) to each scan...")
304
+ Z_deriv = np.zeros_like(Z)
305
+ for i in range(Z.shape[0]):
306
+ row = Z[i, :]
307
+ # Calculate derivative using gradient (handles NaN gracefully in numpy 1.20+)
308
+ # Use the grid spacing for proper derivative calculation
309
+ dx = grid_x[1] - grid_x[0] if len(grid_x) > 1 else 1.0
310
+ # Replace NaN with interpolated values for gradient, then mask back
311
+ valid_mask = ~np.isnan(row)
312
+ if np.sum(valid_mask) > 1:
313
+ # For valid regions, calculate gradient
314
+ deriv = np.gradient(row, dx)
315
+ # Keep NaN where original was NaN
316
+ deriv[~valid_mask] = np.nan
317
+ Z_deriv[i, :] = deriv
318
+ else:
319
+ Z_deriv[i, :] = np.nan
320
+ Z = Z_deriv
299
321
 
300
322
  # Detect an electrochemistry .mpt file in the same folder (if any)
301
323
  # Filter out macOS resource fork files (starting with ._)
@@ -832,7 +832,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
832
832
  "ox: X range",
833
833
  "oy: Y range",
834
834
  "oz: intensity range",
835
- "or: rename"
835
+ "or: rename",
836
+ "pk: peak search"
836
837
  ]
837
838
  col3 = [
838
839
  "et: time range",
@@ -849,6 +850,17 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
849
850
  "b: undo",
850
851
  "q: quit",
851
852
  ]
853
+ # Conditional overwrite shortcuts under (Options)
854
+ last_session = getattr(fig, "_last_session_save_path", None)
855
+ last_style = getattr(fig, "_last_style_export_path", None)
856
+ last_figure = getattr(fig, "_last_figure_export_path", None)
857
+ if last_session:
858
+ col4.append("os: overwrite session")
859
+ if last_style:
860
+ col4.append("ops: overwrite style")
861
+ col4.append("opsg: overwrite style+geom")
862
+ if last_figure:
863
+ col4.append("oe: overwrite figure")
852
864
  # Dynamic column widths
853
865
  w1 = max(len("(Styles)"), *(len(s) for s in col1), 12)
854
866
  w2 = max(len("(Operando)"), *(len(s) for s in col2), 14)
@@ -885,7 +897,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
885
897
  "ox: X range",
886
898
  "oy: Y range",
887
899
  "oz: intensity range",
888
- "or: rename"
900
+ "or: rename",
901
+ "pk: peak search"
889
902
  ]
890
903
  col3 = [
891
904
  "n: crosshair",
@@ -896,6 +909,17 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
896
909
  "b: undo",
897
910
  "q: quit",
898
911
  ]
912
+ # Conditional overwrite shortcuts under (Options)
913
+ last_session = getattr(fig, "_last_session_save_path", None)
914
+ last_style = getattr(fig, "_last_style_export_path", None)
915
+ last_figure = getattr(fig, "_last_figure_export_path", None)
916
+ if last_session:
917
+ col3.append("os: overwrite session")
918
+ if last_style:
919
+ col3.append("ops: overwrite style")
920
+ col3.append("opsg: overwrite style+geom")
921
+ if last_figure:
922
+ col3.append("oe: overwrite figure")
899
923
  w1 = max(len("(Styles)"), *(len(s) for s in col1), 12)
900
924
  w2 = max(len("(Operando)"), *(len(s) for s in col2), 14)
901
925
  w3 = max(len("(Options)"), *(len(s) for s in col3), 16)
@@ -1220,6 +1244,9 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1220
1244
  }
1221
1245
  # EC WASD state (only if ec_ax exists)
1222
1246
  if ec_ax is not None:
1247
+ # For EC, check if ylabel is currently visible (not hidden by user via d5)
1248
+ # EC uses the actual ylabel positioned on right, not a duplicate artist
1249
+ ec_ylabel_visible = bool(ec_ax.get_ylabel()) # Empty string = hidden
1223
1250
  ec_wasd = {
1224
1251
  'top': {'spine': _get_spine_visible(ec_ax, 'top'), 'ticks': ec_ax.xaxis._major_tick_kw.get('tick1On', True),
1225
1252
  'minor': bool(ec_ax.xaxis._minor_tick_kw.get('tick1On', False)),
@@ -1232,11 +1259,11 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1232
1259
  'left': {'spine': _get_spine_visible(ec_ax, 'left'), 'ticks': ec_ax.yaxis._major_tick_kw.get('tick1On', False),
1233
1260
  'minor': bool(ec_ax.yaxis._minor_tick_kw.get('tick1On', False)),
1234
1261
  'labels': ec_ax.yaxis._major_tick_kw.get('label1On', False),
1235
- 'title': bool(ec_ax.get_ylabel())},
1262
+ 'title': False}, # EC ylabel is on right, not left
1236
1263
  'right': {'spine': _get_spine_visible(ec_ax, 'right'), 'ticks': ec_ax.yaxis._major_tick_kw.get('tick2On', True),
1237
1264
  'minor': bool(ec_ax.yaxis._minor_tick_kw.get('tick2On', False)),
1238
1265
  'labels': ec_ax.yaxis._major_tick_kw.get('label2On', True),
1239
- 'title': bool(ec_ax.get_ylabel())},
1266
+ 'title': ec_ylabel_visible}, # True if ylabel is not empty
1240
1267
  }
1241
1268
  else:
1242
1269
  ec_wasd = None
@@ -1266,6 +1293,47 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1266
1293
  'x': getattr(ec_ax.xaxis, 'labelpad', None),
1267
1294
  'y': getattr(ec_ax.yaxis, 'labelpad', None),
1268
1295
  }
1296
+ # Spine and tick widths (l command) for undo
1297
+ op_spines_snap = {}
1298
+ for name in ('bottom', 'top', 'left', 'right'):
1299
+ sp = ax.spines.get(name)
1300
+ if sp:
1301
+ op_spines_snap[name] = float(sp.get_linewidth())
1302
+ op_ticks_snap = {
1303
+ 'x_major': _axis_tick_width(ax.xaxis, 'major'),
1304
+ 'x_minor': _axis_tick_width(ax.xaxis, 'minor'),
1305
+ 'y_major': _axis_tick_width(ax.yaxis, 'major'),
1306
+ 'y_minor': _axis_tick_width(ax.yaxis, 'minor'),
1307
+ }
1308
+ ec_spines_snap = None
1309
+ ec_ticks_snap = None
1310
+ ec_line_style = None
1311
+ if ec_ax is not None:
1312
+ ec_spines_snap = {}
1313
+ for name in ('bottom', 'top', 'left', 'right'):
1314
+ sp = ec_ax.spines.get(name)
1315
+ if sp:
1316
+ ec_spines_snap[name] = float(sp.get_linewidth())
1317
+ ec_ticks_snap = {
1318
+ 'x_major': _axis_tick_width(ec_ax.xaxis, 'major'),
1319
+ 'x_minor': _axis_tick_width(ec_ax.xaxis, 'minor'),
1320
+ 'y_major': _axis_tick_width(ec_ax.yaxis, 'major'),
1321
+ 'y_minor': _axis_tick_width(ec_ax.yaxis, 'minor'),
1322
+ }
1323
+ ln = getattr(ec_ax, '_ec_line', None)
1324
+ if ln is None and ec_ax.lines:
1325
+ try:
1326
+ ln = ec_ax.lines[0]
1327
+ except Exception:
1328
+ ln = None
1329
+ if ln is not None:
1330
+ try:
1331
+ ec_line_style = {
1332
+ 'color': ln.get_color(),
1333
+ 'linewidth': float(ln.get_linewidth() or 1.0),
1334
+ }
1335
+ except Exception:
1336
+ pass
1269
1337
  state_history.append({
1270
1338
  'note': note,
1271
1339
  'fig_size': (fig_w, fig_h),
@@ -1309,6 +1377,11 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1309
1377
  'right_x': float(getattr(ec_ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0) if ec_ax is not None else 0.0,
1310
1378
  'right_y': float(getattr(ec_ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0) if ec_ax is not None else 0.0,
1311
1379
  } if ec_ax is not None else None,
1380
+ 'op_spines': op_spines_snap,
1381
+ 'op_ticks': op_ticks_snap,
1382
+ 'ec_spines': ec_spines_snap,
1383
+ 'ec_ticks': ec_ticks_snap,
1384
+ 'ec_line_style': ec_line_style,
1312
1385
  })
1313
1386
  if len(state_history) > 40:
1314
1387
  state_history.pop(0)
@@ -1546,24 +1619,31 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1546
1619
  ec_ax.tick_params(axis='x', which='minor', **{tick_key: bool(st['minor'])})
1547
1620
  if 'labels' in st:
1548
1621
  ec_ax.tick_params(axis='x', which='major', **{label_key: bool(st['labels'])})
1549
- elif side == 'right': # d - only EC controls
1622
+ elif side == 'right': # d - only EC controls (EC y-axis is on right by default)
1550
1623
  if 'ticks' in st:
1551
1624
  ec_ax.tick_params(axis='y', which='major', left=False, right=bool(st['ticks']))
1552
1625
  if 'minor' in st:
1553
1626
  ec_ax.tick_params(axis='y', which='minor', left=False, right=bool(st['minor']))
1554
1627
  if 'labels' in st:
1555
1628
  ec_ax.tick_params(axis='y', which='major', labelleft=False, labelright=bool(st['labels']))
1629
+ print(f"[DEBUG UNDO] EC right restored: ticks={st.get('ticks')}, labels={st.get('labels')}")
1556
1630
  # Title restoration
1557
1631
  if side == 'top' and 'title' in st:
1558
1632
  setattr(ec_ax, '_top_xlabel_on', bool(st['title']))
1559
1633
  elif side == 'right' and 'title' in st:
1560
1634
  # EC right title is actual ylabel, not duplicate
1635
+ print(f"[DEBUG UNDO] EC right title state: {st['title']}, current ylabel: '{ec_ax.get_ylabel()}'")
1561
1636
  if bool(st['title']):
1562
- # Keep existing ylabel or restore from ec_labels
1563
- pass # ylabel already restored above
1637
+ # Ylabel should be visible - restore from _stored_ylabel if it's currently empty
1638
+ if not ec_ax.get_ylabel() and hasattr(ec_ax, '_stored_ylabel'):
1639
+ ec_ax.set_ylabel(ec_ax._stored_ylabel)
1640
+ print(f"[DEBUG UNDO] Restored EC ylabel from _stored_ylabel: '{ec_ax._stored_ylabel}'")
1564
1641
  else:
1565
1642
  # Hide ylabel
1643
+ if not hasattr(ec_ax, '_stored_ylabel'):
1644
+ ec_ax._stored_ylabel = ec_ax.get_ylabel()
1566
1645
  ec_ax.set_ylabel('')
1646
+ print(f"[DEBUG UNDO] Hid EC ylabel, stored: '{ec_ax._stored_ylabel}'")
1567
1647
  # Re-position titles using UI module functions
1568
1648
  try:
1569
1649
  # Build current tick state dict for UI functions
@@ -1595,11 +1675,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1595
1675
  ec_tick_state['b_ticks'] = st.get('ticks', True)
1596
1676
  ec_tick_state['b_labels'] = st.get('labels', True)
1597
1677
  elif side == 'left':
1598
- ec_tick_state['l_ticks'] = st.get('ticks', False)
1599
- ec_tick_state['l_labels'] = st.get('labels', False)
1678
+ ec_tick_state['l_ticks'] = st.get('ticks', False) # EC: left is off by default
1679
+ ec_tick_state['l_labels'] = st.get('labels', False) # EC: left labels off
1600
1680
  elif side == 'right':
1601
- ec_tick_state['r_ticks'] = st.get('ticks', True)
1602
- ec_tick_state['r_labels'] = st.get('labels', True)
1681
+ ec_tick_state['r_ticks'] = st.get('ticks', True) # EC: right ticks ON by default
1682
+ ec_tick_state['r_labels'] = st.get('labels', True) # EC: right labels ON by default
1683
+ print(f"[DEBUG UNDO] EC tick_state: r_ticks={ec_tick_state.get('r_ticks')}, r_labels={ec_tick_state.get('r_labels')}")
1603
1684
  # Position titles
1604
1685
  _ui_position_top_xlabel(ax, fig, op_tick_state)
1605
1686
  _ui_position_bottom_xlabel(ax, fig, op_tick_state)
@@ -1662,10 +1743,12 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1662
1743
  minor = tick_lengths.get('minor')
1663
1744
  if major is not None:
1664
1745
  ax.tick_params(axis='both', which='major', length=major)
1665
- ec_ax.tick_params(axis='both', which='major', length=major)
1746
+ if ec_ax is not None:
1747
+ ec_ax.tick_params(axis='both', which='major', length=major)
1666
1748
  if minor is not None:
1667
1749
  ax.tick_params(axis='both', which='minor', length=minor)
1668
- ec_ax.tick_params(axis='both', which='minor', length=minor)
1750
+ if ec_ax is not None:
1751
+ ec_ax.tick_params(axis='both', which='minor', length=minor)
1669
1752
  fig._tick_lengths = tick_lengths
1670
1753
  except Exception:
1671
1754
  pass
@@ -1673,10 +1756,68 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
1673
1756
  try:
1674
1757
  tick_dir = snap.get('tick_direction', 'out')
1675
1758
  ax.tick_params(axis='both', which='both', direction=tick_dir)
1676
- ec_ax.tick_params(axis='both', which='both', direction=tick_dir)
1759
+ if ec_ax is not None:
1760
+ ec_ax.tick_params(axis='both', which='both', direction=tick_dir)
1677
1761
  fig._tick_direction = tick_dir
1678
1762
  except Exception:
1679
1763
  pass
1764
+ # Restore spine linewidths and tick widths (l command)
1765
+ try:
1766
+ op_sp = snap.get('op_spines', {})
1767
+ if op_sp:
1768
+ for name, lw in op_sp.items():
1769
+ sp = ax.spines.get(name)
1770
+ if sp is not None and lw is not None:
1771
+ sp.set_linewidth(float(lw))
1772
+ op_tw = snap.get('op_ticks', {})
1773
+ if op_tw:
1774
+ if op_tw.get('x_major') is not None:
1775
+ ax.tick_params(axis='x', which='major', width=op_tw['x_major'])
1776
+ if op_tw.get('x_minor') is not None:
1777
+ ax.tick_params(axis='x', which='minor', width=op_tw['x_minor'])
1778
+ if op_tw.get('y_major') is not None:
1779
+ ax.tick_params(axis='y', which='major', width=op_tw['y_major'])
1780
+ if op_tw.get('y_minor') is not None:
1781
+ ax.tick_params(axis='y', which='minor', width=op_tw['y_minor'])
1782
+ except Exception:
1783
+ pass
1784
+ try:
1785
+ if ec_ax is not None:
1786
+ ec_sp = snap.get('ec_spines', {})
1787
+ if ec_sp:
1788
+ for name, lw in ec_sp.items():
1789
+ sp = ec_ax.spines.get(name)
1790
+ if sp is not None and lw is not None:
1791
+ sp.set_linewidth(float(lw))
1792
+ ec_tw = snap.get('ec_ticks', {})
1793
+ if ec_tw:
1794
+ if ec_tw.get('x_major') is not None:
1795
+ ec_ax.tick_params(axis='x', which='major', width=ec_tw['x_major'])
1796
+ if ec_tw.get('x_minor') is not None:
1797
+ ec_ax.tick_params(axis='x', which='minor', width=ec_tw['x_minor'])
1798
+ if ec_tw.get('y_major') is not None:
1799
+ ec_ax.tick_params(axis='y', which='major', width=ec_tw['y_major'])
1800
+ if ec_tw.get('y_minor') is not None:
1801
+ ec_ax.tick_params(axis='y', which='minor', width=ec_tw['y_minor'])
1802
+ except Exception:
1803
+ pass
1804
+ # Restore EC line style (el command)
1805
+ try:
1806
+ ec_line_style = snap.get('ec_line_style')
1807
+ if ec_line_style and ec_ax is not None:
1808
+ ln = getattr(ec_ax, '_ec_line', None)
1809
+ if ln is None and ec_ax.lines:
1810
+ try:
1811
+ ln = ec_ax.lines[0]
1812
+ except Exception:
1813
+ ln = None
1814
+ if ln is not None:
1815
+ if ec_line_style.get('color') is not None:
1816
+ ln.set_color(ec_line_style['color'])
1817
+ if ec_line_style.get('linewidth') is not None:
1818
+ ln.set_linewidth(float(ec_line_style['linewidth']))
1819
+ except Exception:
1820
+ pass
1680
1821
  # Restore visibility states
1681
1822
  try:
1682
1823
  cb_vis = snap.get('cb_visible')
@@ -2285,6 +2426,234 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2285
2426
  except Exception as e:
2286
2427
  print(f"Save failed: {e}")
2287
2428
  print_menu(); continue
2429
+ if cmd == 'pk':
2430
+ try:
2431
+ import os
2432
+ from .utils import choose_save_path
2433
+ try:
2434
+ from scipy.signal import find_peaks
2435
+ except ImportError:
2436
+ print("Error: scipy is required for peak finding. Install with: pip install scipy")
2437
+ print_menu(); continue
2438
+
2439
+ # Get operando data
2440
+ data_array = np.asarray(im.get_array(), dtype=float)
2441
+ if data_array.ndim != 2 or data_array.size == 0:
2442
+ print("Error: No operando data available.")
2443
+ print_menu(); continue
2444
+
2445
+ extent = im.get_extent() # (left, right, bottom, top)
2446
+ x0, x1, y0, y1 = extent
2447
+ x_min, x_max = (x0, x1) if x0 <= x1 else (x1, x0)
2448
+ y_min, y_max = (y0, y1) if y0 <= y1 else (y1, y0)
2449
+
2450
+ n_scans, n_x_points = data_array.shape
2451
+
2452
+ # Create x-axis array
2453
+ x_axis = np.linspace(x_min, x_max, n_x_points)
2454
+
2455
+ print("\nPeak Search Menu:")
2456
+ print(" 1: Find peaks in X range")
2457
+ print(" e: Explanation of peak searching")
2458
+ print(" q: Back to main menu")
2459
+ sub = _safe_input("Choose option: ").strip().lower()
2460
+
2461
+ if sub == 'e':
2462
+ print("\n" + "="*70)
2463
+ print("PEAK SEARCHING EXPLANATION")
2464
+ print("="*70)
2465
+ print("\nPeak searching identifies local maxima in diffraction patterns.")
2466
+ print("This is useful for tracking how peak positions change over time")
2467
+ print("(or scan number) in operando experiments.\n")
2468
+ print("HOW IT WORKS:")
2469
+ print("1. Select X range: Choose the region where you want to find peaks")
2470
+ print("2. For each scan (file):")
2471
+ print(" - Extract intensity profile in the selected X range")
2472
+ print(" - Find local maxima (peaks) using scipy.signal.find_peaks")
2473
+ print(" - Refine peak positions using quadratic interpolation")
2474
+ print("3. Export results: Peak positions vs file number saved to .txt file\n")
2475
+ print("PARAMETERS:")
2476
+ print("- Prominence: Minimum height of peak relative to surrounding baseline")
2477
+ print(" (Higher = fewer, stronger peaks)")
2478
+ print("- Distance: Minimum separation between peaks (in data points)")
2479
+ print(" (Larger = peaks must be further apart)")
2480
+ print("- Width: Minimum width of peak at half maximum")
2481
+ print(" (Larger = broader peaks only)\n")
2482
+ print("OUTPUT FORMAT:")
2483
+ print("The exported .txt file contains:")
2484
+ print(" Column 1: File number (scan index, 0-based)")
2485
+ print(" Column 2: Peak position (X-axis value)")
2486
+ print(" Column 3: Peak intensity (optional, if enabled)\n")
2487
+ print("="*70 + "\n")
2488
+ print_menu(); continue
2489
+
2490
+ if sub == 'q':
2491
+ print_menu(); continue
2492
+
2493
+ if sub == '1' or sub == '':
2494
+ # Get X range
2495
+ print(f"\nCurrent X range: {x_min:.6g} to {x_max:.6g}")
2496
+ print("Enter X range for peak search (min max), or press Enter to use full range:")
2497
+ x_range_input = _safe_input("X range: ").strip()
2498
+
2499
+ if x_range_input:
2500
+ try:
2501
+ parts = x_range_input.split()
2502
+ if len(parts) >= 2:
2503
+ x_range_min = float(parts[0])
2504
+ x_range_max = float(parts[1])
2505
+ else:
2506
+ print("Invalid format. Use: min max")
2507
+ print_menu(); continue
2508
+ except ValueError:
2509
+ print("Invalid number format.")
2510
+ print_menu(); continue
2511
+ else:
2512
+ x_range_min = x_min
2513
+ x_range_max = x_max
2514
+
2515
+ # Clamp to valid range
2516
+ x_range_min = max(x_min, min(x_max, x_range_min))
2517
+ x_range_max = max(x_min, min(x_max, x_range_max))
2518
+ if x_range_min >= x_range_max:
2519
+ print("Invalid range: min must be < max")
2520
+ print_menu(); continue
2521
+
2522
+ # Find column indices for X range
2523
+ col_min = int(np.argmin(np.abs(x_axis - x_range_min)))
2524
+ col_max = int(np.argmin(np.abs(x_axis - x_range_max)))
2525
+ if col_min > col_max:
2526
+ col_min, col_max = col_max, col_min
2527
+ col_max = min(col_max + 1, n_x_points) # Include endpoint
2528
+
2529
+ # Get parameters for peak finding
2530
+ print("\nPeak finding parameters:")
2531
+ prominence_input = _safe_input("Prominence (relative to max, default 0.1): ").strip()
2532
+ prominence = float(prominence_input) if prominence_input else 0.1
2533
+
2534
+ distance_input = _safe_input("Minimum distance between peaks (data points, default 5): ").strip()
2535
+ distance = int(distance_input) if distance_input else 5
2536
+
2537
+ width_input = _safe_input("Minimum peak width (data points, default 1, 0=disabled): ").strip()
2538
+ width = int(width_input) if width_input else 1
2539
+
2540
+ include_intensity = _safe_input("Include peak intensity in output? (y/n, default n): ").strip().lower() == 'y'
2541
+
2542
+ # Find peaks for each scan
2543
+ print(f"\nFinding peaks in X range [{x_range_min:.6g}, {x_range_max:.6g}]...")
2544
+ results = []
2545
+
2546
+ for scan_idx in range(n_scans):
2547
+ # Extract intensity profile for this scan in X range
2548
+ intensity_profile = data_array[scan_idx, col_min:col_max]
2549
+ x_profile = x_axis[col_min:col_max]
2550
+
2551
+ if len(intensity_profile) < 3:
2552
+ continue
2553
+
2554
+ # Find peaks
2555
+ try:
2556
+ # Calculate prominence threshold
2557
+ max_intensity = np.max(intensity_profile)
2558
+ min_intensity = np.min(intensity_profile)
2559
+ prominence_abs = (max_intensity - min_intensity) * prominence
2560
+
2561
+ peak_kwargs = {
2562
+ 'prominence': prominence_abs if prominence_abs > 0 else None,
2563
+ 'distance': max(1, distance),
2564
+ }
2565
+ if width > 0:
2566
+ peak_kwargs['width'] = width
2567
+
2568
+ # Remove None values
2569
+ peak_kwargs = {k: v for k, v in peak_kwargs.items() if v is not None}
2570
+
2571
+ peak_indices, peak_properties = find_peaks(intensity_profile, **peak_kwargs)
2572
+
2573
+ # Refine peak positions using quadratic interpolation
2574
+ for peak_idx in peak_indices:
2575
+ if peak_idx == 0 or peak_idx == len(intensity_profile) - 1:
2576
+ peak_x = x_profile[peak_idx]
2577
+ peak_intensity = intensity_profile[peak_idx]
2578
+ else:
2579
+ # Quadratic interpolation for sub-pixel accuracy
2580
+ y1 = intensity_profile[peak_idx - 1]
2581
+ y2 = intensity_profile[peak_idx]
2582
+ y3 = intensity_profile[peak_idx + 1]
2583
+ x1 = x_profile[peak_idx - 1]
2584
+ x2 = x_profile[peak_idx]
2585
+ x3 = x_profile[peak_idx + 1]
2586
+
2587
+ denom = (y1 - 2*y2 + y3)
2588
+ if abs(denom) > 1e-12:
2589
+ dx = 0.5 * (y1 - y3) / denom
2590
+ if -0.6 < dx < 0.6:
2591
+ peak_x = x2 + dx * (x3 - x1) / 2.0
2592
+ peak_intensity = y2 + 0.5 * dx * (y3 - y1)
2593
+ else:
2594
+ peak_x = x2
2595
+ peak_intensity = y2
2596
+ else:
2597
+ peak_x = x2
2598
+ peak_intensity = y2
2599
+
2600
+ if include_intensity:
2601
+ results.append((scan_idx, peak_x, peak_intensity))
2602
+ else:
2603
+ results.append((scan_idx, peak_x))
2604
+ except Exception as e:
2605
+ # Skip this scan if peak finding fails
2606
+ continue
2607
+
2608
+ if not results:
2609
+ print("No peaks found in the selected X range.")
2610
+ print_menu(); continue
2611
+
2612
+ # Save results
2613
+ folder = choose_save_path(file_paths, purpose="peak search export")
2614
+ if not folder:
2615
+ print_menu(); continue
2616
+
2617
+ print(f"\nChosen path: {folder}")
2618
+ fname = _safe_input("Export filename (default: peaks.txt): ").strip()
2619
+ if not fname:
2620
+ fname = "peaks.txt"
2621
+ if not fname.endswith('.txt'):
2622
+ fname += '.txt'
2623
+
2624
+ target = fname if os.path.isabs(fname) else os.path.join(folder, fname)
2625
+ if os.path.exists(target):
2626
+ yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
2627
+ if yn != 'y':
2628
+ print_menu(); continue
2629
+
2630
+ # Write results
2631
+ try:
2632
+ with open(target, 'w') as f:
2633
+ if include_intensity:
2634
+ f.write("# File number\tPeak position\tPeak intensity\n")
2635
+ for scan_idx, peak_x, peak_intensity in results:
2636
+ f.write(f"{scan_idx}\t{peak_x:.6f}\t{peak_intensity:.6f}\n")
2637
+ else:
2638
+ f.write("# File number\tPeak position\n")
2639
+ for result in results:
2640
+ if len(result) == 2:
2641
+ scan_idx, peak_x = result
2642
+ f.write(f"{scan_idx}\t{peak_x:.6f}\n")
2643
+ else:
2644
+ scan_idx, peak_x, _ = result
2645
+ f.write(f"{scan_idx}\t{peak_x:.6f}\n")
2646
+ print(f"Peak positions exported to {target}")
2647
+ print(f"Found {len(results)} peaks across {len(set(r[0] for r in results))} scans")
2648
+ except Exception as e:
2649
+ print(f"Error saving file: {e}")
2650
+ else:
2651
+ print("Invalid option.")
2652
+ except Exception as e:
2653
+ print(f"Error in peak search: {e}")
2654
+ import traceback
2655
+ traceback.print_exc()
2656
+ print_menu(); continue
2288
2657
  if cmd == 'h':
2289
2658
  # Always read fresh value from attribute to avoid stale cached value
2290
2659
  ax_h_in = getattr(ax, '_fixed_ax_h_in', ax_h_in)
@@ -2724,6 +3093,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2724
3093
  try: axis._stored_ylabel = axis.get_ylabel()
2725
3094
  except Exception: axis._stored_ylabel = ''
2726
3095
  axis.set_ylabel("")
3096
+ # Set flag for right title state (used by save/export)
3097
+ axis._right_ylabel_on = bool(wasd_state['right']['title'])
2727
3098
  # Left ylabel is disabled for EC (hide any duplicate artist)
2728
3099
  # Note: EC uses the actual ylabel which is already on the right side
2729
3100
  else:
@@ -2747,7 +3118,10 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
2747
3118
  if 'left' in changed_sides:
2748
3119
  _ui_position_left_ylabel(axis, fig, current_tick_state)
2749
3120
  if 'right' in changed_sides:
2750
- _ui_position_right_ylabel(axis, fig, current_tick_state)
3121
+ # EC axes use actual ylabel on right, not duplicate artist
3122
+ # Skip _ui_position_right_ylabel for EC to avoid creating unwanted duplicate
3123
+ if not is_ec:
3124
+ _ui_position_right_ylabel(axis, fig, current_tick_state)
2751
3125
 
2752
3126
  print(_colorize_inline_commands("WASD toggles: direction (w/a/s/d) x action (1..5)"))
2753
3127
  print(_colorize_inline_commands(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title"))
@@ -4039,7 +4413,7 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
4039
4413
  print_menu()
4040
4414
  elif cmd == 'i':
4041
4415
  # Load a .bps/.bpsg/.bpcfg style and apply
4042
- # Applies style properties from commands: oc, ow, ew, h, el, t, l, f, g, r
4416
+ # Applies: oc, ow, ew, h, el, t, l, f, g, r, v; .bpsg also applies ox, oy, oz, or, et, ex, ey, er (axes_geometry + ec y_mode)
4043
4417
  try:
4044
4418
  path = choose_style_file(file_paths, purpose="style import")
4045
4419
  if not path: