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/__init__.py +1 -1
- batplot/args.py +22 -4
- batplot/batch.py +12 -0
- batplot/batplot.py +340 -126
- batplot/converters.py +170 -122
- batplot/cpc_interactive.py +319 -161
- batplot/data/USER_MANUAL.md +49 -0
- batplot/electrochem_interactive.py +120 -80
- batplot/interactive.py +1763 -75
- batplot/modes.py +12 -11
- batplot/operando.py +22 -0
- batplot/operando_ec_interactive.py +390 -16
- batplot/session.py +85 -9
- batplot/style.py +198 -21
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/METADATA +1 -1
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/RECORD +20 -20
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/WHEEL +1 -1
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/licenses/LICENSE +0 -0
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.4.dist-info → batplot-1.8.6.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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':
|
|
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':
|
|
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
|
-
#
|
|
1563
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|