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

Files changed (38) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +10 -0
  5. batplot/interactive.py +10 -0
  6. batplot/modes.py +12 -12
  7. batplot/operando_ec_interactive.py +4 -4
  8. batplot/session.py +17 -0
  9. batplot/version_check.py +1 -1
  10. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  11. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
  12. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  13. batplot_backup_20251221_101150/__init__.py +5 -0
  14. batplot_backup_20251221_101150/args.py +625 -0
  15. batplot_backup_20251221_101150/batch.py +1176 -0
  16. batplot_backup_20251221_101150/batplot.py +3589 -0
  17. batplot_backup_20251221_101150/cif.py +823 -0
  18. batplot_backup_20251221_101150/cli.py +149 -0
  19. batplot_backup_20251221_101150/color_utils.py +547 -0
  20. batplot_backup_20251221_101150/config.py +198 -0
  21. batplot_backup_20251221_101150/converters.py +204 -0
  22. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  23. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  24. batplot_backup_20251221_101150/interactive.py +3894 -0
  25. batplot_backup_20251221_101150/manual.py +323 -0
  26. batplot_backup_20251221_101150/modes.py +799 -0
  27. batplot_backup_20251221_101150/operando.py +603 -0
  28. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  29. batplot_backup_20251221_101150/plotting.py +228 -0
  30. batplot_backup_20251221_101150/readers.py +2607 -0
  31. batplot_backup_20251221_101150/session.py +2951 -0
  32. batplot_backup_20251221_101150/style.py +1441 -0
  33. batplot_backup_20251221_101150/ui.py +790 -0
  34. batplot_backup_20251221_101150/utils.py +1046 -0
  35. batplot_backup_20251221_101150/version_check.py +253 -0
  36. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  37. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  38. {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,603 @@
1
+ """Operando (time/sequence) contour plotting utilities.
2
+
3
+ This module provides functions to create operando contour plots from a folder
4
+ of diffraction data files. Operando plots show how diffraction patterns change
5
+ over time (or scan number) as a 2D intensity map.
6
+
7
+ WHAT IS AN OPERANDO PLOT?
8
+ ------------------------
9
+ An operando plot is a 2D visualization where:
10
+ - X-axis: Diffraction angle (2θ) or momentum transfer (Q)
11
+ - Y-axis: Time/scan number (which file/measurement in sequence)
12
+ - Color/Z-axis: Intensity (how bright the diffraction signal is)
13
+
14
+ Example use cases:
15
+ - Watching a material transform during heating (phase transitions)
16
+ - Monitoring battery electrode changes during cycling
17
+ - Tracking crystal growth over time
18
+ - Observing chemical reactions in real-time
19
+
20
+ HOW IT WORKS:
21
+ ------------
22
+ 1. Scan folder for XRD/PDF/XAS or other data files
23
+ 2. Load each file as one "scan" (one row in the contour)
24
+ 3. Create a common X-axis grid (interpolate all scans to same grid)
25
+ 4. Stack all scans vertically to form a 2D array
26
+ 5. Display as intensity contour (color map)
27
+ 6. Optionally add electrochemistry/temperature/other data as side panel (if .mpt file present)
28
+
29
+ AXIS MODE DETECTION:
30
+ -------------------
31
+ The X-axis type is determined automatically:
32
+ - If --xaxis Q specified → Use Q-space
33
+ - If files are .qye → Use Q-space (already in Q)
34
+ - If --wl specified → Convert 2θ to Q using wavelength
35
+ - Other operando data (such as PDF/XAS or others) → Plot the first two columns as X and Y
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from pathlib import Path
41
+ from typing import Optional, Tuple, Dict, Any
42
+
43
+ import numpy as np
44
+ import matplotlib.pyplot as plt
45
+
46
+ from .converters import convert_to_qye
47
+ from .readers import robust_loadtxt_skipheader, read_mpt_file
48
+
49
+ # Import colorbar drawing function for non-interactive mode
50
+ try:
51
+ from .operando_ec_interactive import _draw_custom_colorbar
52
+ except ImportError:
53
+ # Fallback if interactive module not available
54
+ _draw_custom_colorbar = None
55
+
56
+ SUPPORTED_EXT = {".xy", ".xye", ".qye", ".dat"}
57
+ # Standard diffraction file extensions that have known x-axis meanings
58
+ KNOWN_DIFFRACTION_EXT = {".xy", ".xye", ".qye", ".dat", ".nor", ".chik", ".chir"}
59
+ # File types to exclude from operando data (system/session files and electrochemistry)
60
+ EXCLUDED_EXT = {".mpt", ".pkl", ".json", ".txt", ".md", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".DS_Store"}
61
+
62
+ # Keep the colorbar width deterministic (in inches) so interactive tweaks or saved
63
+ # sessions never pick up whatever Matplotlib auto-sized for the current figure.
64
+ DEFAULT_COLORBAR_WIDTH_IN = 0.23
65
+
66
+ _two_theta_re = re.compile(r"2[tT]heta|2th", re.IGNORECASE)
67
+ _q_re = re.compile(r"^q$", re.IGNORECASE)
68
+ _r_re = re.compile(r"^r(adial)?$", re.IGNORECASE)
69
+
70
+ def _natural_sort_key(path: Path) -> list:
71
+ """Generate a natural sorting key for filenames with numbers.
72
+
73
+ Converts 'file_10.xy' to ['file_', 10, '.xy'] so numerical parts are sorted numerically.
74
+ This ensures file_2.xy comes before file_10.xy (natural order).
75
+ """
76
+ parts = []
77
+ for match in re.finditer(r'(\d+|\D+)', path.name):
78
+ text = match.group(0)
79
+ if text.isdigit():
80
+ parts.append(int(text))
81
+ else:
82
+ parts.append(text.lower())
83
+ return parts
84
+
85
+ def _infer_axis_mode(args, any_qye: bool, has_unknown_ext: bool):
86
+ # Priority: explicit --xaxis, else .qye presence (Q), else wavelength (Q), else default 2theta with warning
87
+ # If unknown extensions are present, use "user defined" mode
88
+ if has_unknown_ext and not args.xaxis:
89
+ return "user_defined"
90
+ if args.xaxis:
91
+ axis_str = args.xaxis.strip()
92
+ if _q_re.match(axis_str):
93
+ return "Q"
94
+ if _r_re.match(axis_str):
95
+ return "r"
96
+ if _two_theta_re.search(axis_str):
97
+ return "2theta"
98
+ print(f"[operando] Unrecognized --xaxis '{args.xaxis}', assuming 2theta.")
99
+ return "2theta"
100
+ if any_qye:
101
+ return "Q"
102
+ if getattr(args, 'wl', None) is not None:
103
+ return "Q"
104
+ print("[operando] No --xaxis or --wl supplied and no .qye files; assuming 2theta (degrees). Use --xaxis 2theta to silence this message.")
105
+ return "2theta"
106
+
107
+ def _load_curve(path: Path, readcol=None):
108
+ data = robust_loadtxt_skipheader(str(path))
109
+ if data.ndim == 1:
110
+ if data.size < 2:
111
+ raise ValueError(f"File {path} has insufficient numeric data")
112
+ x = data[0::2]
113
+ y = data[1::2]
114
+ else:
115
+ # Handle --readcol flag to select specific columns
116
+ if readcol:
117
+ x_col, y_col = readcol
118
+ # Convert from 1-indexed to 0-indexed
119
+ x_col_idx = x_col - 1
120
+ y_col_idx = y_col - 1
121
+ if x_col_idx < 0 or x_col_idx >= data.shape[1]:
122
+ raise ValueError(f"X column {x_col} out of range in {path} (has {data.shape[1]} columns)")
123
+ if y_col_idx < 0 or y_col_idx >= data.shape[1]:
124
+ raise ValueError(f"Y column {y_col} out of range in {path} (has {data.shape[1]} columns)")
125
+ x = data[:, x_col_idx]
126
+ y = data[:, y_col_idx]
127
+ else:
128
+ x = data[:,0]
129
+ y = data[:,1]
130
+ return np.asarray(x, float), np.asarray(y, float)
131
+
132
+ def _maybe_convert_to_Q(x, wl):
133
+ # Accept degrees (2theta) -> Q
134
+ # Q = 4π sin(theta)/λ ; theta = (2θ)/2
135
+ theta = np.radians(x/2.0)
136
+ return 4.0 * np.pi * np.sin(theta) / wl
137
+
138
+ def plot_operando_folder(folder: str, args) -> Tuple[plt.Figure, plt.Axes, Dict[str, Any]]:
139
+ """
140
+ Plot operando contour from a folder of diffraction files.
141
+
142
+ HOW IT WORKS:
143
+ ------------
144
+ This function creates a 2D intensity contour plot from a sequence of
145
+ diffraction patterns. The workflow:
146
+
147
+ 1. **Scan folder**: Find all diffraction data files (.xy, .xye, .qye, .dat)
148
+ 2. **Load data**: Read each file as one "scan" (one time point)
149
+ 3. **Determine axis mode**: Detect if data is in 2θ or Q space
150
+ 4. **Convert if needed**: Convert 2θ to Q if wavelength provided
151
+ 5. **Create common grid**: Interpolate all scans to same X-axis grid
152
+ 6. **Stack scans**: Build 2D array (n_scans × n_x_points)
153
+ 7. **Create contour**: Display as intensity map using imshow/pcolormesh
154
+ 8. **Add colorbar**: Show intensity scale
155
+ 9. **Optional EC panel**: If .mpt file found, add electrochemistry side panel
156
+
157
+ DATA INTERPOLATION:
158
+ ------------------
159
+ Different scans might have different X-axis points (different angle ranges
160
+ or resolutions). We create a common grid by:
161
+ - Finding global min/max X values across all scans
162
+ - Creating evenly spaced grid points
163
+ - Interpolating each scan to this common grid
164
+
165
+ This ensures all scans align perfectly for the contour plot.
166
+
167
+ ELECTROCHEMISTRY INTEGRATION:
168
+ ----------------------------
169
+ If a .mpt file is found in the folder, an electrochemistry plot is added
170
+ as a side panel. This shows voltage/capacity vs time alongside the
171
+ diffraction contour, allowing correlation between structural changes
172
+ (diffraction) and electrochemical behavior (voltage).
173
+
174
+ Args:
175
+ folder: Path to directory containing diffraction data files
176
+ args: Argument namespace with attributes:
177
+ - xaxis: X-axis type ('Q', '2theta', 'r', etc.)
178
+ - wl: Wavelength for 2θ→Q conversion (if needed)
179
+ - raw: Whether to use raw intensity (no processing)
180
+ - interactive: Whether to launch interactive menu
181
+ - savefig/out: Optional output filename
182
+
183
+ Returns:
184
+ Tuple of (figure, axes, metadata_dict) where:
185
+ - figure: Matplotlib figure object
186
+ - axes: Main axes object (contour plot)
187
+ - metadata_dict: Dictionary containing:
188
+ - 'files': List of file paths used
189
+ - 'axis_mode': Detected axis mode ('Q', '2theta', etc.)
190
+ - 'x_grid': Common X-axis grid (1D array)
191
+ - 'imshow': Image object (for colormap changes)
192
+ - 'colorbar': Colorbar object (for intensity scale)
193
+ - 'has_ec': Whether EC panel was added
194
+ - 'ec_ax': EC axes object (if has_ec is True)
195
+ """
196
+ p = Path(folder)
197
+ if not p.is_dir():
198
+ raise FileNotFoundError(f"Not a directory: {folder}")
199
+
200
+ # Accept all file types except those in EXCLUDED_EXT
201
+ # Filter out macOS resource fork files starting with ._
202
+ # Also check that filename is not .DS_Store (which has no extension)
203
+ files = sorted([f for f in p.iterdir()
204
+ if f.is_file()
205
+ and f.suffix.lower() not in EXCLUDED_EXT
206
+ and f.name != ".DS_Store"
207
+ and not f.name.startswith("._")],
208
+ key=_natural_sort_key)
209
+ if not files:
210
+ raise FileNotFoundError("No data files found in folder (excluding system/session files)")
211
+
212
+ # Check if we have .qye files to help determine axis mode
213
+ any_qye = any(f.suffix.lower() == ".qye" for f in files)
214
+ # Since we accept all file types now, has_unknown_ext is effectively always True unless all are in KNOWN_DIFFRACTION_EXT
215
+ has_unknown_ext = not all(f.suffix.lower() in KNOWN_DIFFRACTION_EXT for f in files)
216
+ axis_mode = _infer_axis_mode(args, any_qye, has_unknown_ext)
217
+ wl = getattr(args, 'wl', None)
218
+
219
+ x_arrays = []
220
+ y_arrays = []
221
+ readcol = getattr(args, 'readcol', None)
222
+ for f in files:
223
+ try:
224
+ x, y = _load_curve(f, readcol=readcol)
225
+ except Exception as e:
226
+ print(f"Skip {f.name}: {e}")
227
+ continue
228
+ # Convert to Q if needed (but not for user_defined mode)
229
+ if axis_mode == "Q":
230
+ if f.suffix.lower() == ".qye":
231
+ pass # already Q
232
+ else:
233
+ if wl is None:
234
+ # If user wants Q without wavelength we cannot proceed for this file
235
+ print(f"Skip {f.name}: need wavelength (--wl) for Q conversion")
236
+ continue
237
+ x = _maybe_convert_to_Q(x, wl)
238
+ # No normalization - keep raw intensity values
239
+ x_arrays.append(x)
240
+ y_arrays.append(y)
241
+
242
+ if not x_arrays:
243
+ raise RuntimeError("No curves loaded after filtering/conversion.")
244
+
245
+ # ====================================================================
246
+ # CREATE COMMON X-AXIS GRID AND INTERPOLATE ALL SCANS
247
+ # ====================================================================
248
+ # Different scans might have:
249
+ # - Different angle ranges (some from 10-80°, others from 20-70°)
250
+ # - Different resolutions (some with 1000 points, others with 500)
251
+ # - Slightly different X values (measurement variations)
252
+ #
253
+ # To create a contour plot, all scans must have the SAME X-axis grid.
254
+ # We solve this by:
255
+ # 1. Finding the global min/max X values (covers all scans)
256
+ # 2. Creating a common evenly-spaced grid
257
+ # 3. Interpolating each scan to this common grid
258
+ #
259
+ # This ensures perfect alignment for the 2D contour visualization.
260
+ # ====================================================================
261
+
262
+ # STEP 1: Find global X-axis range
263
+ # Find the minimum and maximum X values across ALL scans
264
+ # This determines the range of our common grid
265
+ xmin = min(arr.min() for arr in x_arrays if arr.size) # Global minimum
266
+ xmax = max(arr.max() for arr in x_arrays if arr.size) # Global maximum
267
+
268
+ # STEP 2: Determine grid resolution
269
+ # Use the maximum number of points from any scan as the grid size
270
+ # This preserves the highest resolution available
271
+ base_len = int(max(arr.size for arr in x_arrays))
272
+
273
+ # STEP 3: Create evenly-spaced common grid
274
+ # np.linspace creates evenly spaced points from xmin to xmax
275
+ # Example: xmin=10, xmax=80, base_len=1000 → 1000 evenly spaced points
276
+ grid_x = np.linspace(xmin, xmax, base_len)
277
+
278
+ # STEP 4: Interpolate each scan to common grid
279
+ # For each scan, interpolate its Y values to the common X grid
280
+ stack = []
281
+ for x, y in zip(x_arrays, y_arrays):
282
+ if x.size < 2:
283
+ # Can't interpolate with less than 2 points, fill with NaN
284
+ interp = np.full_like(grid_x, np.nan)
285
+ else:
286
+ # Linear interpolation: find Y value at each grid_x point
287
+ # np.interp() does linear interpolation:
288
+ # - For grid_x[i] between x[j] and x[j+1], interpolate between y[j] and y[j+1]
289
+ # - left=np.nan: If grid_x < x.min(), use NaN (outside scan range)
290
+ # - right=np.nan: If grid_x > x.max(), use NaN (outside scan range)
291
+ interp = np.interp(grid_x, x, y, left=np.nan, right=np.nan)
292
+ stack.append(interp)
293
+
294
+ # STEP 5: Stack all interpolated scans into 2D array
295
+ # np.vstack() stacks arrays vertically (one scan per row)
296
+ # Result shape: (n_scans, n_x_points)
297
+ # Example: 50 scans × 1000 points = (50, 1000) array
298
+ Z = np.vstack(stack) # shape (n_scans, n_x)
299
+
300
+ # Detect an electrochemistry .mpt file in the same folder (if any)
301
+ # Filter out macOS resource fork files (starting with ._)
302
+ mpt_files = sorted([f for f in p.iterdir() if f.suffix.lower() == ".mpt" and not f.name.startswith("._")], key=_natural_sort_key) # pick first if present
303
+ has_ec = len(mpt_files) > 0
304
+ ec_ax = None
305
+
306
+ if has_ec:
307
+ # Wider canvas to accommodate side-by-side plots
308
+ fig = plt.figure(figsize=(11, 6))
309
+ gs = fig.add_gridspec(nrows=1, ncols=2, width_ratios=[3.5, 1.2], wspace=0.25)
310
+ ax = fig.add_subplot(gs[0, 0])
311
+ else:
312
+ fig, ax = plt.subplots(figsize=(8,6))
313
+ # Use imshow for speed; mask nans
314
+ Zm = np.ma.masked_invalid(Z)
315
+ extent = (grid_x.min(), grid_x.max(), 0, Zm.shape[0]-1)
316
+ # Bottom-to-top visual order (scan 0 at bottom) to match EC time progression -> origin='lower'
317
+ im = ax.imshow(Zm, aspect='auto', origin='lower', extent=extent, cmap='viridis', interpolation='nearest')
318
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
319
+ setattr(im, '_operando_cmap_name', 'viridis')
320
+ # Create custom colorbar axes on the left (will be positioned by layout function)
321
+ # Create a dummy axes that will be replaced by the custom colorbar in interactive menu
322
+ cbar_ax = fig.add_axes([0.0, 0.0, 0.01, 0.01]) # Temporary position, will be repositioned
323
+ # Create a mock colorbar object for compatibility with existing code
324
+ # The actual colorbar will be drawn by _draw_custom_colorbar in the interactive menu
325
+ class MockColorbar:
326
+ def __init__(self, ax, im):
327
+ self.ax = ax
328
+ self._im = im
329
+ def set_label(self, label):
330
+ ax._colorbar_label = label
331
+ def update_normal(self, im):
332
+ # This will be replaced by _update_custom_colorbar in interactive menu
333
+ pass
334
+ cbar = MockColorbar(cbar_ax, im)
335
+ # Store label
336
+ cbar_ax._colorbar_label = 'Intensity'
337
+ ax.set_ylabel('Scan index')
338
+ if axis_mode == 'Q':
339
+ # Use mathtext for reliable superscript minus; plain unicode '⁻' can fail with some fonts
340
+ ax.set_xlabel(r'Q (Å$^{-1}$)') # renders as Å^{-1}
341
+ elif axis_mode == 'r':
342
+ ax.set_xlabel(r'r (Å)')
343
+ elif axis_mode == 'user_defined':
344
+ ax.set_xlabel('user defined')
345
+ else:
346
+ ax.set_xlabel('2θ (deg)')
347
+ # No title for operando plot (requested)
348
+
349
+ # If an EC .mpt exists, attach it to the right with the same height (Voltage vs Time in hours)
350
+ if has_ec:
351
+ try:
352
+ ec_path = mpt_files[0]
353
+
354
+ # Check if user specified custom columns via --readcolmpt
355
+ readcol_mpt = None
356
+ if hasattr(args, 'readcol_by_ext') and '.mpt' in args.readcol_by_ext:
357
+ readcol_mpt = args.readcol_by_ext['.mpt']
358
+
359
+ if readcol_mpt:
360
+ # User explicitly specified columns - respect their choice
361
+ data = robust_loadtxt_skipheader(str(ec_path))
362
+ if data.ndim == 1:
363
+ data = data.reshape(1, -1)
364
+ if data.shape[1] < 2:
365
+ raise ValueError(f"MPT file {ec_path.name} has insufficient columns")
366
+
367
+ # Apply column selection (1-indexed -> 0-indexed)
368
+ x_col, y_col = readcol_mpt
369
+ x_col_idx = x_col - 1
370
+ y_col_idx = y_col - 1
371
+ if x_col_idx < 0 or x_col_idx >= data.shape[1]:
372
+ raise ValueError(f"X column {x_col} out of range in {ec_path.name} (has {data.shape[1]} columns)")
373
+ if y_col_idx < 0 or y_col_idx >= data.shape[1]:
374
+ raise ValueError(f"Y column {y_col} out of range in {ec_path.name} (has {data.shape[1]} columns)")
375
+
376
+ x_data = data[:, x_col_idx]
377
+ y_data = data[:, y_col_idx]
378
+ current_mA = None
379
+ # User-specified: plot exactly as specified (X on x-axis, Y on y-axis)
380
+ x_label = f'Column {x_col}'
381
+ y_label = f'Column {y_col}'
382
+ else:
383
+ # Auto-detect format: Read time series from .mpt
384
+ result = read_mpt_file(str(ec_path), mode='time')
385
+
386
+ # Check if we got labels (5 elements) or old format (3 elements)
387
+ if len(result) == 5:
388
+ x_data, y_data, current_mA, x_label, y_label = result
389
+ # For EC-Lab files: x_label='Time (h)', y_label='Voltage (V)'
390
+ # For simple files: x_label could be 'Time(h)', 'time', etc.
391
+ # EC-Lab files: read_mpt_file already converts time from seconds to hours
392
+ # operando plots with voltage on X-axis and time on Y-axis
393
+
394
+ # Check if labels indicate time/voltage data (flexible matching)
395
+ x_lower = x_label.lower().replace(' ', '').replace('_', '')
396
+ y_lower = y_label.lower().replace(' ', '').replace('_', '')
397
+ has_time_in_x = 'time' in x_lower
398
+ has_voltage_in_x = 'voltage' in x_lower or 'ewe' in x_lower
399
+ has_time_in_y = 'time' in y_lower
400
+ has_voltage_in_y = 'voltage' in y_lower or 'ewe' in y_lower
401
+
402
+ is_time_voltage = (has_time_in_x or has_time_in_y) and (has_voltage_in_x or has_voltage_in_y)
403
+
404
+ if x_label == 'Time (h)' and y_label == 'Voltage (V)':
405
+ # EC-Lab file: time is already in hours from read_mpt_file, just swap axes
406
+ time_h = np.asarray(x_data, float) # Already in hours, no conversion needed
407
+ voltage_v = np.asarray(y_data, float)
408
+ x_data = voltage_v
409
+ y_data = time_h
410
+ x_label = 'Voltage (V)'
411
+ y_label = 'Time (h)'
412
+ elif is_time_voltage:
413
+ # Simple file with time/voltage columns
414
+ # Determine which column is which, then arrange: voltage on X, time on Y
415
+ if has_time_in_x and has_voltage_in_y:
416
+ # Columns are: Time, Voltage -> swap to Voltage, Time
417
+ time_h = np.asarray(x_data, float)
418
+ voltage_v = np.asarray(y_data, float)
419
+ x_data = voltage_v
420
+ y_data = time_h
421
+ x_label = 'Voltage (V)'
422
+ y_label = 'Time (h)'
423
+ elif has_voltage_in_x and has_time_in_y:
424
+ # Columns are: Voltage, Time -> already correct order
425
+ voltage_v = np.asarray(x_data, float)
426
+ time_h = np.asarray(y_data, float)
427
+ x_data = voltage_v
428
+ y_data = time_h
429
+ x_label = 'Voltage (V)'
430
+ y_label = 'Time (h)'
431
+ else:
432
+ # Ambiguous or both in same column - default behavior
433
+ x_data = np.asarray(x_data, float)
434
+ y_data = np.asarray(y_data, float)
435
+ else:
436
+ # Generic file: use raw data as-is, keep original labels
437
+ x_data = np.asarray(x_data, float)
438
+ y_data = np.asarray(y_data, float)
439
+ else:
440
+ # Old format compatibility (shouldn't happen anymore)
441
+ x_data, y_data, current_mA = result
442
+ x_data = np.asarray(y_data, float)
443
+ y_data = np.asarray(x_data, float) / 3600.0
444
+ x_label, y_label = 'Voltage (V)', 'Time (h)'
445
+
446
+ # Add the EC axes on the right
447
+ ec_ax = fig.add_subplot(gs[0, 1])
448
+ ln_ec, = ec_ax.plot(x_data, y_data, lw=1.0, color='tab:blue')
449
+ ec_ax.set_xlabel(x_label)
450
+ ec_ax.set_ylabel(y_label)
451
+ # Match interactive defaults: put EC Y axis on the right
452
+ try:
453
+ ec_ax.yaxis.tick_right()
454
+ ec_ax.yaxis.set_label_position('right')
455
+ _title = ec_ax.get_title()
456
+ if isinstance(_title, str) and _title.strip():
457
+ ec_ax.set_title(_title, loc='right')
458
+ except Exception:
459
+ pass
460
+ # Keep a clean look, no grid
461
+ # Align visually: ensure similar vertical span display
462
+ try:
463
+ # Remove vertical margins and clamp to exact data bounds
464
+ ec_ax.margins(y=0)
465
+ ymin = float(np.nanmin(y_data)) if getattr(np, 'nanmin', None) else float(np.min(y_data))
466
+ ymax = float(np.nanmax(y_data)) if getattr(np, 'nanmax', None) else float(np.max(y_data))
467
+ ec_ax.set_ylim(ymin, ymax)
468
+ except Exception:
469
+ pass
470
+ # Add a small right margin on EC X to give space for right-side ticks/labels
471
+ try:
472
+ x0, x1 = ec_ax.get_xlim()
473
+ xr = (x1 - x0) if x1 > x0 else 0.0
474
+ if xr > 0:
475
+ ec_ax.set_xlim(x0, x1 + 0.02 * xr)
476
+ setattr(ec_ax, '_xlim_expanded_default', True)
477
+ except Exception:
478
+ pass
479
+ # Stash EC data and line for interactive transforms
480
+ try:
481
+ ec_ax._ec_time_h = y_data # Store y_data (could be time or any y value)
482
+ ec_ax._ec_voltage_v = x_data # Store x_data (could be voltage or any x value)
483
+ ec_ax._ec_current_mA = current_mA
484
+ ec_ax._ec_line = ln_ec
485
+ ec_ax._ec_y_mode = 'time' # or 'ions'
486
+ ec_ax._ion_annots = []
487
+ ec_ax._ion_params = {"mass_mg": None, "cap_per_ion_mAh_g": None}
488
+ except Exception:
489
+ pass
490
+ except Exception as e:
491
+ print(f"[operando] Failed to attach electrochem plot: {e}")
492
+
493
+ # --- Default layout: set operando plot width to 5 inches (centered) ---
494
+ try:
495
+ fig_w_in, fig_h_in = fig.get_size_inches()
496
+ # Current geometry in fractions
497
+ ax_x0, ax_y0, ax_wf, ax_hf = ax.get_position().bounds
498
+ cb_x0, cb_y0, cb_wf, cb_hf = cbar.ax.get_position().bounds
499
+ # Convert to inches
500
+ desired_ax_w_in = 5.0
501
+ ax_h_in = ax_hf * fig_h_in
502
+ cb_w_in = min(DEFAULT_COLORBAR_WIDTH_IN, fig_w_in)
503
+ cb_gap_in = max(0.0, (ax_x0 - (cb_x0 + cb_wf)) * fig_w_in)
504
+ ec_gap_in = 0.0
505
+ ec_w_in = 0.0
506
+ if ec_ax is not None:
507
+ ec_x0, ec_y0, ec_wf, ec_hf = ec_ax.get_position().bounds
508
+ ec_gap_in = max(0.0, (ec_x0 - (ax_x0 + ax_wf)) * fig_w_in)
509
+ ec_w_in = ec_wf * fig_w_in
510
+ # Match interactive default: shrink EC gap and rebalance widths
511
+ try:
512
+ # Decrease gap more aggressively with a sensible minimum
513
+ # Increase the multiplier from 0.2 to 0.35 for more spacing
514
+ ec_gap_in = max(0.05, ec_gap_in * 0.35)
515
+ # Transfer a fraction of width from EC to operando while keeping total similar
516
+ combined = (desired_ax_w_in if desired_ax_w_in > 0 else ax_wf * fig_w_in) + ec_w_in
517
+ ax_w_in_current = desired_ax_w_in if desired_ax_w_in > 0 else (ax_wf * fig_w_in)
518
+ if combined > 0 and ec_w_in > 0.5:
519
+ transfer = min(ec_w_in * 0.18, combined * 0.12)
520
+ min_ec = 0.8
521
+ if ec_w_in - transfer < min_ec:
522
+ transfer = max(0.0, ec_w_in - min_ec)
523
+ desired_ax_w_in = ax_w_in_current + transfer
524
+ ec_w_in = max(min_ec, ec_w_in - transfer)
525
+ except Exception:
526
+ pass
527
+ # Apply gap adjustment when EC panel exists (multiply by 0.75 to move colorbar closer)
528
+ cb_gap_in = cb_gap_in * 0.75
529
+ else:
530
+ # When no EC panel, increase gap to move colorbar further left (multiply by 1.3)
531
+ cb_gap_in = cb_gap_in * 1.1
532
+ # Clamp desired width if it would overflow the canvas
533
+ reserved = cb_w_in + cb_gap_in + ec_gap_in + ec_w_in
534
+ max_ax_w = max(0.25, fig_w_in - reserved - 0.02)
535
+ ax_w_in = min(desired_ax_w_in, max_ax_w)
536
+ # Convert inches to fractions
537
+ ax_wf_new = max(0.0, ax_w_in / fig_w_in)
538
+ ax_hf_new = max(0.0, ax_h_in / fig_h_in)
539
+ cb_wf_new = max(0.0, cb_w_in / fig_w_in)
540
+ cb_gap_f = max(0.0, cb_gap_in / fig_w_in)
541
+ ec_gap_f = max(0.0, ec_gap_in / fig_w_in)
542
+ ec_wf_new = max(0.0, ec_w_in / fig_w_in)
543
+ # Center group horizontally
544
+ total_wf = cb_wf_new + cb_gap_f + ax_wf_new + ec_gap_f + ec_wf_new
545
+ group_left = 0.5 - total_wf / 2.0
546
+ y0 = 0.5 - ax_hf_new / 2.0
547
+ # Positions
548
+ cb_x0_new = group_left
549
+ ax_x0_new = cb_x0_new + cb_wf_new + cb_gap_f
550
+ ec_x0_new = ax_x0_new + ax_wf_new + ec_gap_f if ec_ax is not None else None
551
+ # Apply
552
+ ax.set_position([ax_x0_new, y0, ax_wf_new, ax_hf_new])
553
+ cbar.ax.set_position([cb_x0_new, y0, cb_wf_new, ax_hf_new])
554
+ if ec_ax is not None and ec_x0_new is not None:
555
+ ec_ax.set_position([ec_x0_new, y0, ec_wf_new, ax_hf_new])
556
+
557
+ # Draw the colorbar (even in non-interactive mode)
558
+ if _draw_custom_colorbar is not None:
559
+ try:
560
+ cbar_label = getattr(cbar.ax, '_colorbar_label', 'Intensity')
561
+ _draw_custom_colorbar(cbar.ax, im, cbar_label, 'normal')
562
+ except Exception:
563
+ pass
564
+
565
+ # Persist inches so interactive menu can pick them up
566
+ try:
567
+ setattr(cbar.ax, '_fixed_cb_w_in', cb_w_in)
568
+ # Store both names for compatibility across interactive menus
569
+ setattr(cbar.ax, '_fixed_cb_gap_in', cb_gap_in)
570
+ setattr(cbar.ax, '_fixed_gap_in', cb_gap_in)
571
+ # Mark as adjusted so interactive mode doesn't apply 0.75 multiplier again
572
+ setattr(cbar.ax, '_cb_gap_adjusted', True)
573
+ if ec_ax is not None:
574
+ setattr(ec_ax, '_fixed_ec_gap_in', ec_gap_in)
575
+ setattr(ec_ax, '_fixed_ec_w_in', ec_w_in)
576
+ # Mark as adjusted so interactive menu won't adjust twice
577
+ setattr(ec_ax, '_ec_gap_adjusted', True)
578
+ setattr(ec_ax, '_ec_op_width_adjusted', True)
579
+ setattr(ax, '_fixed_ax_w_in', ax_w_in)
580
+ setattr(ax, '_fixed_ax_h_in', ax_h_in)
581
+ except Exception:
582
+ pass
583
+ try:
584
+ fig.canvas.draw()
585
+ except Exception:
586
+ fig.canvas.draw_idle()
587
+ except Exception:
588
+ # Non-fatal: keep Matplotlib's default layout
589
+ pass
590
+
591
+ meta = {
592
+ 'files': [f.name for f in files],
593
+ 'axis_mode': axis_mode,
594
+ 'x_grid': grid_x,
595
+ 'imshow': im,
596
+ 'colorbar': cbar,
597
+ 'has_ec': bool(has_ec),
598
+ }
599
+ if ec_ax is not None:
600
+ meta['ec_ax'] = ec_ax
601
+ return fig, ax, meta
602
+
603
+ __all__ = ["plot_operando_folder"]