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/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)