batplot 1.8.3__py3-none-any.whl → 1.8.5__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.
- batplot/__init__.py +1 -1
- batplot/args.py +22 -4
- batplot/batch.py +12 -0
- batplot/batplot.py +234 -91
- batplot/cli.py +14 -0
- batplot/converters.py +170 -122
- batplot/cpc_interactive.py +33 -21
- batplot/data/USER_MANUAL.md +49 -0
- batplot/electrochem_interactive.py +63 -36
- batplot/interactive.py +1547 -73
- batplot/operando.py +22 -0
- batplot/operando_ec_interactive.py +232 -2
- batplot/session.py +128 -148
- batplot/style.py +89 -2
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/METADATA +4 -9
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/RECORD +20 -20
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/WHEEL +1 -1
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/licenses/LICENSE +0 -0
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.3.dist-info → batplot-1.8.5.dist-info}/top_level.txt +0 -0
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",
|
|
@@ -885,7 +886,8 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
|
|
|
885
886
|
"ox: X range",
|
|
886
887
|
"oy: Y range",
|
|
887
888
|
"oz: intensity range",
|
|
888
|
-
"or: rename"
|
|
889
|
+
"or: rename",
|
|
890
|
+
"pk: peak search"
|
|
889
891
|
]
|
|
890
892
|
col3 = [
|
|
891
893
|
"n: crosshair",
|
|
@@ -2285,6 +2287,234 @@ def operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=None):
|
|
|
2285
2287
|
except Exception as e:
|
|
2286
2288
|
print(f"Save failed: {e}")
|
|
2287
2289
|
print_menu(); continue
|
|
2290
|
+
if cmd == 'pk':
|
|
2291
|
+
try:
|
|
2292
|
+
import os
|
|
2293
|
+
from .utils import choose_save_path
|
|
2294
|
+
try:
|
|
2295
|
+
from scipy.signal import find_peaks
|
|
2296
|
+
except ImportError:
|
|
2297
|
+
print("Error: scipy is required for peak finding. Install with: pip install scipy")
|
|
2298
|
+
print_menu(); continue
|
|
2299
|
+
|
|
2300
|
+
# Get operando data
|
|
2301
|
+
data_array = np.asarray(im.get_array(), dtype=float)
|
|
2302
|
+
if data_array.ndim != 2 or data_array.size == 0:
|
|
2303
|
+
print("Error: No operando data available.")
|
|
2304
|
+
print_menu(); continue
|
|
2305
|
+
|
|
2306
|
+
extent = im.get_extent() # (left, right, bottom, top)
|
|
2307
|
+
x0, x1, y0, y1 = extent
|
|
2308
|
+
x_min, x_max = (x0, x1) if x0 <= x1 else (x1, x0)
|
|
2309
|
+
y_min, y_max = (y0, y1) if y0 <= y1 else (y1, y0)
|
|
2310
|
+
|
|
2311
|
+
n_scans, n_x_points = data_array.shape
|
|
2312
|
+
|
|
2313
|
+
# Create x-axis array
|
|
2314
|
+
x_axis = np.linspace(x_min, x_max, n_x_points)
|
|
2315
|
+
|
|
2316
|
+
print("\nPeak Search Menu:")
|
|
2317
|
+
print(" 1: Find peaks in X range")
|
|
2318
|
+
print(" e: Explanation of peak searching")
|
|
2319
|
+
print(" q: Back to main menu")
|
|
2320
|
+
sub = _safe_input("Choose option: ").strip().lower()
|
|
2321
|
+
|
|
2322
|
+
if sub == 'e':
|
|
2323
|
+
print("\n" + "="*70)
|
|
2324
|
+
print("PEAK SEARCHING EXPLANATION")
|
|
2325
|
+
print("="*70)
|
|
2326
|
+
print("\nPeak searching identifies local maxima in diffraction patterns.")
|
|
2327
|
+
print("This is useful for tracking how peak positions change over time")
|
|
2328
|
+
print("(or scan number) in operando experiments.\n")
|
|
2329
|
+
print("HOW IT WORKS:")
|
|
2330
|
+
print("1. Select X range: Choose the region where you want to find peaks")
|
|
2331
|
+
print("2. For each scan (file):")
|
|
2332
|
+
print(" - Extract intensity profile in the selected X range")
|
|
2333
|
+
print(" - Find local maxima (peaks) using scipy.signal.find_peaks")
|
|
2334
|
+
print(" - Refine peak positions using quadratic interpolation")
|
|
2335
|
+
print("3. Export results: Peak positions vs file number saved to .txt file\n")
|
|
2336
|
+
print("PARAMETERS:")
|
|
2337
|
+
print("- Prominence: Minimum height of peak relative to surrounding baseline")
|
|
2338
|
+
print(" (Higher = fewer, stronger peaks)")
|
|
2339
|
+
print("- Distance: Minimum separation between peaks (in data points)")
|
|
2340
|
+
print(" (Larger = peaks must be further apart)")
|
|
2341
|
+
print("- Width: Minimum width of peak at half maximum")
|
|
2342
|
+
print(" (Larger = broader peaks only)\n")
|
|
2343
|
+
print("OUTPUT FORMAT:")
|
|
2344
|
+
print("The exported .txt file contains:")
|
|
2345
|
+
print(" Column 1: File number (scan index, 0-based)")
|
|
2346
|
+
print(" Column 2: Peak position (X-axis value)")
|
|
2347
|
+
print(" Column 3: Peak intensity (optional, if enabled)\n")
|
|
2348
|
+
print("="*70 + "\n")
|
|
2349
|
+
print_menu(); continue
|
|
2350
|
+
|
|
2351
|
+
if sub == 'q':
|
|
2352
|
+
print_menu(); continue
|
|
2353
|
+
|
|
2354
|
+
if sub == '1' or sub == '':
|
|
2355
|
+
# Get X range
|
|
2356
|
+
print(f"\nCurrent X range: {x_min:.6g} to {x_max:.6g}")
|
|
2357
|
+
print("Enter X range for peak search (min max), or press Enter to use full range:")
|
|
2358
|
+
x_range_input = _safe_input("X range: ").strip()
|
|
2359
|
+
|
|
2360
|
+
if x_range_input:
|
|
2361
|
+
try:
|
|
2362
|
+
parts = x_range_input.split()
|
|
2363
|
+
if len(parts) >= 2:
|
|
2364
|
+
x_range_min = float(parts[0])
|
|
2365
|
+
x_range_max = float(parts[1])
|
|
2366
|
+
else:
|
|
2367
|
+
print("Invalid format. Use: min max")
|
|
2368
|
+
print_menu(); continue
|
|
2369
|
+
except ValueError:
|
|
2370
|
+
print("Invalid number format.")
|
|
2371
|
+
print_menu(); continue
|
|
2372
|
+
else:
|
|
2373
|
+
x_range_min = x_min
|
|
2374
|
+
x_range_max = x_max
|
|
2375
|
+
|
|
2376
|
+
# Clamp to valid range
|
|
2377
|
+
x_range_min = max(x_min, min(x_max, x_range_min))
|
|
2378
|
+
x_range_max = max(x_min, min(x_max, x_range_max))
|
|
2379
|
+
if x_range_min >= x_range_max:
|
|
2380
|
+
print("Invalid range: min must be < max")
|
|
2381
|
+
print_menu(); continue
|
|
2382
|
+
|
|
2383
|
+
# Find column indices for X range
|
|
2384
|
+
col_min = int(np.argmin(np.abs(x_axis - x_range_min)))
|
|
2385
|
+
col_max = int(np.argmin(np.abs(x_axis - x_range_max)))
|
|
2386
|
+
if col_min > col_max:
|
|
2387
|
+
col_min, col_max = col_max, col_min
|
|
2388
|
+
col_max = min(col_max + 1, n_x_points) # Include endpoint
|
|
2389
|
+
|
|
2390
|
+
# Get parameters for peak finding
|
|
2391
|
+
print("\nPeak finding parameters:")
|
|
2392
|
+
prominence_input = _safe_input("Prominence (relative to max, default 0.1): ").strip()
|
|
2393
|
+
prominence = float(prominence_input) if prominence_input else 0.1
|
|
2394
|
+
|
|
2395
|
+
distance_input = _safe_input("Minimum distance between peaks (data points, default 5): ").strip()
|
|
2396
|
+
distance = int(distance_input) if distance_input else 5
|
|
2397
|
+
|
|
2398
|
+
width_input = _safe_input("Minimum peak width (data points, default 1, 0=disabled): ").strip()
|
|
2399
|
+
width = int(width_input) if width_input else 1
|
|
2400
|
+
|
|
2401
|
+
include_intensity = _safe_input("Include peak intensity in output? (y/n, default n): ").strip().lower() == 'y'
|
|
2402
|
+
|
|
2403
|
+
# Find peaks for each scan
|
|
2404
|
+
print(f"\nFinding peaks in X range [{x_range_min:.6g}, {x_range_max:.6g}]...")
|
|
2405
|
+
results = []
|
|
2406
|
+
|
|
2407
|
+
for scan_idx in range(n_scans):
|
|
2408
|
+
# Extract intensity profile for this scan in X range
|
|
2409
|
+
intensity_profile = data_array[scan_idx, col_min:col_max]
|
|
2410
|
+
x_profile = x_axis[col_min:col_max]
|
|
2411
|
+
|
|
2412
|
+
if len(intensity_profile) < 3:
|
|
2413
|
+
continue
|
|
2414
|
+
|
|
2415
|
+
# Find peaks
|
|
2416
|
+
try:
|
|
2417
|
+
# Calculate prominence threshold
|
|
2418
|
+
max_intensity = np.max(intensity_profile)
|
|
2419
|
+
min_intensity = np.min(intensity_profile)
|
|
2420
|
+
prominence_abs = (max_intensity - min_intensity) * prominence
|
|
2421
|
+
|
|
2422
|
+
peak_kwargs = {
|
|
2423
|
+
'prominence': prominence_abs if prominence_abs > 0 else None,
|
|
2424
|
+
'distance': max(1, distance),
|
|
2425
|
+
}
|
|
2426
|
+
if width > 0:
|
|
2427
|
+
peak_kwargs['width'] = width
|
|
2428
|
+
|
|
2429
|
+
# Remove None values
|
|
2430
|
+
peak_kwargs = {k: v for k, v in peak_kwargs.items() if v is not None}
|
|
2431
|
+
|
|
2432
|
+
peak_indices, peak_properties = find_peaks(intensity_profile, **peak_kwargs)
|
|
2433
|
+
|
|
2434
|
+
# Refine peak positions using quadratic interpolation
|
|
2435
|
+
for peak_idx in peak_indices:
|
|
2436
|
+
if peak_idx == 0 or peak_idx == len(intensity_profile) - 1:
|
|
2437
|
+
peak_x = x_profile[peak_idx]
|
|
2438
|
+
peak_intensity = intensity_profile[peak_idx]
|
|
2439
|
+
else:
|
|
2440
|
+
# Quadratic interpolation for sub-pixel accuracy
|
|
2441
|
+
y1 = intensity_profile[peak_idx - 1]
|
|
2442
|
+
y2 = intensity_profile[peak_idx]
|
|
2443
|
+
y3 = intensity_profile[peak_idx + 1]
|
|
2444
|
+
x1 = x_profile[peak_idx - 1]
|
|
2445
|
+
x2 = x_profile[peak_idx]
|
|
2446
|
+
x3 = x_profile[peak_idx + 1]
|
|
2447
|
+
|
|
2448
|
+
denom = (y1 - 2*y2 + y3)
|
|
2449
|
+
if abs(denom) > 1e-12:
|
|
2450
|
+
dx = 0.5 * (y1 - y3) / denom
|
|
2451
|
+
if -0.6 < dx < 0.6:
|
|
2452
|
+
peak_x = x2 + dx * (x3 - x1) / 2.0
|
|
2453
|
+
peak_intensity = y2 + 0.5 * dx * (y3 - y1)
|
|
2454
|
+
else:
|
|
2455
|
+
peak_x = x2
|
|
2456
|
+
peak_intensity = y2
|
|
2457
|
+
else:
|
|
2458
|
+
peak_x = x2
|
|
2459
|
+
peak_intensity = y2
|
|
2460
|
+
|
|
2461
|
+
if include_intensity:
|
|
2462
|
+
results.append((scan_idx, peak_x, peak_intensity))
|
|
2463
|
+
else:
|
|
2464
|
+
results.append((scan_idx, peak_x))
|
|
2465
|
+
except Exception as e:
|
|
2466
|
+
# Skip this scan if peak finding fails
|
|
2467
|
+
continue
|
|
2468
|
+
|
|
2469
|
+
if not results:
|
|
2470
|
+
print("No peaks found in the selected X range.")
|
|
2471
|
+
print_menu(); continue
|
|
2472
|
+
|
|
2473
|
+
# Save results
|
|
2474
|
+
folder = choose_save_path(file_paths, purpose="peak search export")
|
|
2475
|
+
if not folder:
|
|
2476
|
+
print_menu(); continue
|
|
2477
|
+
|
|
2478
|
+
print(f"\nChosen path: {folder}")
|
|
2479
|
+
fname = _safe_input("Export filename (default: peaks.txt): ").strip()
|
|
2480
|
+
if not fname:
|
|
2481
|
+
fname = "peaks.txt"
|
|
2482
|
+
if not fname.endswith('.txt'):
|
|
2483
|
+
fname += '.txt'
|
|
2484
|
+
|
|
2485
|
+
target = fname if os.path.isabs(fname) else os.path.join(folder, fname)
|
|
2486
|
+
if os.path.exists(target):
|
|
2487
|
+
yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
|
|
2488
|
+
if yn != 'y':
|
|
2489
|
+
print_menu(); continue
|
|
2490
|
+
|
|
2491
|
+
# Write results
|
|
2492
|
+
try:
|
|
2493
|
+
with open(target, 'w') as f:
|
|
2494
|
+
if include_intensity:
|
|
2495
|
+
f.write("# File number\tPeak position\tPeak intensity\n")
|
|
2496
|
+
for scan_idx, peak_x, peak_intensity in results:
|
|
2497
|
+
f.write(f"{scan_idx}\t{peak_x:.6f}\t{peak_intensity:.6f}\n")
|
|
2498
|
+
else:
|
|
2499
|
+
f.write("# File number\tPeak position\n")
|
|
2500
|
+
for result in results:
|
|
2501
|
+
if len(result) == 2:
|
|
2502
|
+
scan_idx, peak_x = result
|
|
2503
|
+
f.write(f"{scan_idx}\t{peak_x:.6f}\n")
|
|
2504
|
+
else:
|
|
2505
|
+
scan_idx, peak_x, _ = result
|
|
2506
|
+
f.write(f"{scan_idx}\t{peak_x:.6f}\n")
|
|
2507
|
+
print(f"Peak positions exported to {target}")
|
|
2508
|
+
print(f"Found {len(results)} peaks across {len(set(r[0] for r in results))} scans")
|
|
2509
|
+
except Exception as e:
|
|
2510
|
+
print(f"Error saving file: {e}")
|
|
2511
|
+
else:
|
|
2512
|
+
print("Invalid option.")
|
|
2513
|
+
except Exception as e:
|
|
2514
|
+
print(f"Error in peak search: {e}")
|
|
2515
|
+
import traceback
|
|
2516
|
+
traceback.print_exc()
|
|
2517
|
+
print_menu(); continue
|
|
2288
2518
|
if cmd == 'h':
|
|
2289
2519
|
# Always read fresh value from attribute to avoid stale cached value
|
|
2290
2520
|
ax_h_in = getattr(ax, '_fixed_ax_h_in', ax_h_in)
|