batplot 1.8.1__py3-none-any.whl → 1.8.3__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 +2 -0
- batplot/batch.py +23 -0
- batplot/batplot.py +101 -12
- batplot/cpc_interactive.py +25 -3
- batplot/electrochem_interactive.py +20 -4
- batplot/interactive.py +19 -15
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +218 -0
- batplot/style.py +21 -2
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
- batplot-1.8.3.dist-info/RECORD +75 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
- batplot_backup_20251221_101150/__init__.py +5 -0
- batplot_backup_20251221_101150/args.py +625 -0
- batplot_backup_20251221_101150/batch.py +1176 -0
- batplot_backup_20251221_101150/batplot.py +3589 -0
- batplot_backup_20251221_101150/cif.py +823 -0
- batplot_backup_20251221_101150/cli.py +149 -0
- batplot_backup_20251221_101150/color_utils.py +547 -0
- batplot_backup_20251221_101150/config.py +198 -0
- batplot_backup_20251221_101150/converters.py +204 -0
- batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
- batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
- batplot_backup_20251221_101150/interactive.py +3894 -0
- batplot_backup_20251221_101150/manual.py +323 -0
- batplot_backup_20251221_101150/modes.py +799 -0
- batplot_backup_20251221_101150/operando.py +603 -0
- batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
- batplot_backup_20251221_101150/plotting.py +228 -0
- batplot_backup_20251221_101150/readers.py +2607 -0
- batplot_backup_20251221_101150/session.py +2951 -0
- batplot_backup_20251221_101150/style.py +1441 -0
- batplot_backup_20251221_101150/ui.py +790 -0
- batplot_backup_20251221_101150/utils.py +1046 -0
- batplot_backup_20251221_101150/version_check.py +253 -0
- batplot-1.8.1.dist-info/RECORD +0 -52
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.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"]
|