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.
- batplot/__init__.py +1 -1
- batplot/args.py +2 -0
- batplot/batplot.py +44 -4
- batplot/cpc_interactive.py +10 -0
- batplot/interactive.py +10 -0
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +17 -0
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
- {batplot-1.8.1.dist-info → batplot-1.8.2.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 → batplot-1.8.2.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,3589 @@
|
|
|
1
|
+
"""batplot - Interactive plotting for 1D, electrochemistry and operando contour plots.
|
|
2
|
+
It is designed for researchers working on materials science and electrochemistry, aiming to speed up the plotting process.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
# Import all dependencies at module level
|
|
8
|
+
from .electrochem_interactive import electrochem_interactive_menu
|
|
9
|
+
from .args import parse_args as _bp_parse_args
|
|
10
|
+
from .interactive import interactive_menu
|
|
11
|
+
from .batch import batch_process, batch_process_ec
|
|
12
|
+
from .converters import convert_to_qye
|
|
13
|
+
from .session import (
|
|
14
|
+
dump_session as _bp_dump_session,
|
|
15
|
+
load_ec_session,
|
|
16
|
+
load_operando_session,
|
|
17
|
+
load_cpc_session,
|
|
18
|
+
_apply_axes_bbox as _session_apply_axes_bbox,
|
|
19
|
+
)
|
|
20
|
+
from .operando import plot_operando_folder
|
|
21
|
+
from .plotting import update_labels
|
|
22
|
+
from .utils import _confirm_overwrite, normalize_label_text
|
|
23
|
+
from .readers import (
|
|
24
|
+
read_csv_file,
|
|
25
|
+
read_fullprof_rowwise,
|
|
26
|
+
robust_loadtxt_skipheader,
|
|
27
|
+
read_gr_file,
|
|
28
|
+
read_mpt_file,
|
|
29
|
+
read_ec_csv_file,
|
|
30
|
+
read_ec_csv_dqdv_file,
|
|
31
|
+
read_mpt_dqdv_file,
|
|
32
|
+
read_csv_time_voltage,
|
|
33
|
+
read_mpt_time_voltage,
|
|
34
|
+
read_cs_b_csv_file,
|
|
35
|
+
is_cs_b_format,
|
|
36
|
+
_load_csv_header_and_rows,
|
|
37
|
+
)
|
|
38
|
+
from .cif import (
|
|
39
|
+
simulate_cif_pattern_Q,
|
|
40
|
+
cif_reflection_positions,
|
|
41
|
+
list_reflections_with_hkl,
|
|
42
|
+
build_hkl_label_map_from_list,
|
|
43
|
+
)
|
|
44
|
+
from .ui import (
|
|
45
|
+
apply_font_changes as _ui_apply_font_changes,
|
|
46
|
+
sync_fonts as _ui_sync_fonts,
|
|
47
|
+
position_top_xlabel as _ui_position_top_xlabel,
|
|
48
|
+
position_right_ylabel as _ui_position_right_ylabel,
|
|
49
|
+
update_tick_visibility as _ui_update_tick_visibility,
|
|
50
|
+
ensure_text_visibility as _ui_ensure_text_visibility,
|
|
51
|
+
resize_plot_frame as _ui_resize_plot_frame,
|
|
52
|
+
resize_canvas as _ui_resize_canvas,
|
|
53
|
+
)
|
|
54
|
+
from .style import (
|
|
55
|
+
print_style_info as _bp_print_style_info,
|
|
56
|
+
export_style_config as _bp_export_style_config,
|
|
57
|
+
apply_style_config as _bp_apply_style_config,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
import numpy as np
|
|
61
|
+
import sys
|
|
62
|
+
import os
|
|
63
|
+
import pickle
|
|
64
|
+
import json
|
|
65
|
+
import random
|
|
66
|
+
import argparse
|
|
67
|
+
import re
|
|
68
|
+
import matplotlib as _mpl
|
|
69
|
+
import matplotlib.pyplot as plt
|
|
70
|
+
from matplotlib.ticker import AutoMinorLocator, NullFormatter
|
|
71
|
+
|
|
72
|
+
# Try to import optional interactive menus
|
|
73
|
+
try:
|
|
74
|
+
from .operando_ec_interactive import operando_ec_interactive_menu
|
|
75
|
+
except ImportError:
|
|
76
|
+
operando_ec_interactive_menu = None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
from .cpc_interactive import cpc_interactive_menu, _generate_similar_color
|
|
80
|
+
except ImportError:
|
|
81
|
+
cpc_interactive_menu = None
|
|
82
|
+
# Fallback function if import fails
|
|
83
|
+
def _generate_similar_color(base_color):
|
|
84
|
+
"""Generate a similar but distinguishable color for discharge from charge color."""
|
|
85
|
+
try:
|
|
86
|
+
from matplotlib.colors import to_rgb, rgb_to_hsv, hsv_to_rgb
|
|
87
|
+
rgb = to_rgb(base_color)
|
|
88
|
+
hsv = rgb_to_hsv(rgb)
|
|
89
|
+
h, s, v = hsv
|
|
90
|
+
h_new = (h + 0.04) % 1.0
|
|
91
|
+
s_new = max(0.3, s * 0.85)
|
|
92
|
+
v_new = max(0.4, v * 0.9)
|
|
93
|
+
rgb_new = hsv_to_rgb([h_new, s_new, v_new])
|
|
94
|
+
# Convert numpy array to tuple to avoid truth value ambiguity
|
|
95
|
+
if hasattr(rgb_new, 'tolist'):
|
|
96
|
+
return tuple(rgb_new.tolist())
|
|
97
|
+
return tuple(rgb_new)
|
|
98
|
+
except Exception:
|
|
99
|
+
try:
|
|
100
|
+
from matplotlib.colors import to_rgb
|
|
101
|
+
rgb = to_rgb(base_color)
|
|
102
|
+
return tuple(max(0, c * 0.7) for c in rgb)
|
|
103
|
+
except Exception:
|
|
104
|
+
return base_color
|
|
105
|
+
|
|
106
|
+
# Global state variables (used by interactive menus and style system)
|
|
107
|
+
keep_canvas_fixed = False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
ALLFILES_KNOWN_EXTENSIONS = {'.xye', '.xy', '.qye', '.dat', '.csv', '.gr', '.nor', '.chik', '.chir', '.txt', '.mpt'}
|
|
111
|
+
ALLFILES_EXCLUDED_EXTENSIONS = {'.cif', '.pkl', '.py', '.md', '.json', '.yml', '.yaml', '.sh', '.bat'}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _natural_sort_key(filename: str) -> list:
|
|
115
|
+
"""Generate a natural sorting key for filenames with numbers.
|
|
116
|
+
|
|
117
|
+
Converts 'file_10.xy' to ['file_', 10, '.xy'] so numerical parts are sorted numerically.
|
|
118
|
+
This ensures file_2.xy comes before file_10.xy (natural order).
|
|
119
|
+
"""
|
|
120
|
+
parts = []
|
|
121
|
+
for match in re.finditer(r'(\d+|\D+)', filename):
|
|
122
|
+
text = match.group(0)
|
|
123
|
+
if text.isdigit():
|
|
124
|
+
parts.append(int(text))
|
|
125
|
+
else:
|
|
126
|
+
parts.append(text.lower())
|
|
127
|
+
return parts
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _prepare_allfiles_directory(target_dir: str, args, use_relative_paths: bool = False,
|
|
131
|
+
allowed_exts: set[str] | None = None) -> None:
|
|
132
|
+
"""Populate args.files with data files under target_dir (optionally filtered by extension)."""
|
|
133
|
+
all_xy_files = []
|
|
134
|
+
unknown_ext_files = [] if allowed_exts is None else None
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
entries = sorted(os.listdir(target_dir), key=_natural_sort_key)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
print(f"Failed to list directory '{target_dir}': {exc}")
|
|
140
|
+
exit(1)
|
|
141
|
+
|
|
142
|
+
for f in entries:
|
|
143
|
+
full_path = os.path.join(target_dir, f)
|
|
144
|
+
if not os.path.isfile(full_path):
|
|
145
|
+
continue
|
|
146
|
+
ext = os.path.splitext(f)[1].lower()
|
|
147
|
+
if ext in ALLFILES_EXCLUDED_EXTENSIONS or not ext:
|
|
148
|
+
continue
|
|
149
|
+
if allowed_exts is not None:
|
|
150
|
+
if ext not in allowed_exts:
|
|
151
|
+
continue
|
|
152
|
+
else:
|
|
153
|
+
# Default mode: keep unknown types but track for warning
|
|
154
|
+
if ext not in ALLFILES_KNOWN_EXTENSIONS and unknown_ext_files is not None:
|
|
155
|
+
unknown_ext_files.append(f)
|
|
156
|
+
store_path = f if use_relative_paths else full_path
|
|
157
|
+
all_xy_files.append(store_path)
|
|
158
|
+
|
|
159
|
+
if not all_xy_files:
|
|
160
|
+
if allowed_exts:
|
|
161
|
+
ext_list = ", ".join(sorted(allowed_exts))
|
|
162
|
+
print(f"No {ext_list} files found in directory: {target_dir}")
|
|
163
|
+
else:
|
|
164
|
+
print(f"No data files found in directory: {target_dir}")
|
|
165
|
+
exit(1)
|
|
166
|
+
|
|
167
|
+
if allowed_exts is None and unknown_ext_files:
|
|
168
|
+
print(f"Warning: Found {len(unknown_ext_files)} file(s) with unknown extension(s):")
|
|
169
|
+
for uf in unknown_ext_files[:5]:
|
|
170
|
+
print(f" - {uf}")
|
|
171
|
+
if len(unknown_ext_files) > 5:
|
|
172
|
+
print(f" ... and {len(unknown_ext_files) - 5} more")
|
|
173
|
+
print("These will be read as 2-column (x, y) data.")
|
|
174
|
+
if not args.xaxis:
|
|
175
|
+
print("Tip: Use --xaxis to specify the x-axis type (e.g., --xaxis 2theta, --xaxis Q, --xaxis r)")
|
|
176
|
+
|
|
177
|
+
print(f"Found {len(all_xy_files)} files to plot together")
|
|
178
|
+
args.files = all_xy_files
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _maybe_expand_allfiles_argument(args, ec_mode_active: bool = False) -> None:
|
|
182
|
+
"""Handle 'allfiles' argument appearing anywhere by expanding directory contents."""
|
|
183
|
+
if ec_mode_active or not args.files:
|
|
184
|
+
return
|
|
185
|
+
token_info = []
|
|
186
|
+
non_token_entries = []
|
|
187
|
+
for original in args.files:
|
|
188
|
+
lower = original.lower()
|
|
189
|
+
if lower.startswith('all') and lower.endswith('files'):
|
|
190
|
+
middle = lower[3:-5]
|
|
191
|
+
token_info.append((original, middle))
|
|
192
|
+
else:
|
|
193
|
+
non_token_entries.append(original)
|
|
194
|
+
if not token_info:
|
|
195
|
+
return
|
|
196
|
+
if len(token_info) > 1:
|
|
197
|
+
print("Specify only one all*files token (e.g., allfiles or allxyfiles) at a time.")
|
|
198
|
+
exit(1)
|
|
199
|
+
_, middle = token_info[0]
|
|
200
|
+
if len(non_token_entries) > 1:
|
|
201
|
+
print("When using all*files tokens, provide zero or one directory argument.")
|
|
202
|
+
exit(1)
|
|
203
|
+
if middle:
|
|
204
|
+
ext = f".{middle}"
|
|
205
|
+
if ext not in ALLFILES_KNOWN_EXTENSIONS:
|
|
206
|
+
allowed = ", ".join(sorted(e.strip('.') for e in ALLFILES_KNOWN_EXTENSIONS))
|
|
207
|
+
print(f"Unknown all-files token 'all{middle}files'. Allowed extensions: {allowed}")
|
|
208
|
+
exit(1)
|
|
209
|
+
allowed_exts = {ext}
|
|
210
|
+
else:
|
|
211
|
+
allowed_exts = None
|
|
212
|
+
if len(non_token_entries) == 1:
|
|
213
|
+
dir_arg = non_token_entries[0]
|
|
214
|
+
if not os.path.isdir(dir_arg):
|
|
215
|
+
print(f"Directory not found: {dir_arg}")
|
|
216
|
+
exit(1)
|
|
217
|
+
target_dir = os.path.abspath(dir_arg)
|
|
218
|
+
use_relative = False
|
|
219
|
+
else:
|
|
220
|
+
target_dir = os.getcwd()
|
|
221
|
+
use_relative = True
|
|
222
|
+
_prepare_allfiles_directory(target_dir, args, use_relative_paths=use_relative,
|
|
223
|
+
allowed_exts=allowed_exts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def batplot_main() -> int:
|
|
227
|
+
"""
|
|
228
|
+
Main entry point for batplot CLI.
|
|
229
|
+
|
|
230
|
+
This is the central routing function that:
|
|
231
|
+
1. Parses command-line arguments
|
|
232
|
+
2. Determines which mode to use (XY, EC, Operando, Batch, etc.)
|
|
233
|
+
3. Routes to the appropriate handler function
|
|
234
|
+
4. Handles errors and returns exit codes
|
|
235
|
+
|
|
236
|
+
HOW ROUTING WORKS:
|
|
237
|
+
-----------------
|
|
238
|
+
batplot supports multiple modes, determined by command-line flags:
|
|
239
|
+
|
|
240
|
+
XY MODE (default):
|
|
241
|
+
batplot file1.xy file2.xy → Normal XY plotting
|
|
242
|
+
batplot allfiles → Plot all files together
|
|
243
|
+
batplot --all → Batch mode (separate files)
|
|
244
|
+
|
|
245
|
+
EC MODES (electrochemistry):
|
|
246
|
+
batplot --gc file.mpt --mass 7.0 → Galvanostatic cycling
|
|
247
|
+
batplot --cv file.mpt → Cyclic voltammetry
|
|
248
|
+
batplot --dqdv file.csv → Differential capacity
|
|
249
|
+
batplot --cpc file.csv → Capacity per cycle
|
|
250
|
+
|
|
251
|
+
OPERANDO MODE:
|
|
252
|
+
batplot --operando folder/ → Contour plot from folder
|
|
253
|
+
|
|
254
|
+
BATCH MODES:
|
|
255
|
+
batplot --all → Batch XY mode
|
|
256
|
+
batplot --gc --all --mass 7.0 → Batch EC mode
|
|
257
|
+
|
|
258
|
+
CONVERSION:
|
|
259
|
+
batplot --convert file.xy --wl 1.54 → Convert 2θ to Q
|
|
260
|
+
|
|
261
|
+
The function checks flags in priority order and routes accordingly.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Exit code: 0 for success, non-zero for error
|
|
265
|
+
(Follows Unix convention: 0 = success, non-zero = error)
|
|
266
|
+
"""
|
|
267
|
+
# ====================================================================
|
|
268
|
+
# STEP 1: PARSE COMMAND-LINE ARGUMENTS
|
|
269
|
+
# ====================================================================
|
|
270
|
+
# Parse all command-line arguments into a namespace object.
|
|
271
|
+
# This includes files, flags (--gc, --cv, etc.), and options (--mass, --wl, etc.)
|
|
272
|
+
# ====================================================================
|
|
273
|
+
args = _bp_parse_args()
|
|
274
|
+
|
|
275
|
+
# ====================================================================
|
|
276
|
+
# STEP 2: VALIDATE INPUT
|
|
277
|
+
# ====================================================================
|
|
278
|
+
# Check if user provided any input (files or special flags).
|
|
279
|
+
# If nothing provided, show help message and exit gracefully.
|
|
280
|
+
# ====================================================================
|
|
281
|
+
|
|
282
|
+
# Check for special flags that don't require file arguments
|
|
283
|
+
# These modes can work without explicit file arguments (e.g., --all scans directory)
|
|
284
|
+
has_special_flag = any([
|
|
285
|
+
getattr(args, 'gc', False), # Galvanostatic cycling mode
|
|
286
|
+
getattr(args, 'cv', False), # Cyclic voltammetry mode
|
|
287
|
+
getattr(args, 'dqdv', False), # Differential capacity mode
|
|
288
|
+
getattr(args, 'cpc', False), # Capacity per cycle mode
|
|
289
|
+
getattr(args, 'operando', False), # Operando contour mode
|
|
290
|
+
getattr(args, 'all', None) is not None, # Batch mode flag
|
|
291
|
+
getattr(args, 'convert', None) is not None, # Conversion mode
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
# If no files AND no special flags, nothing to do
|
|
295
|
+
if not args.files and not has_special_flag:
|
|
296
|
+
print("No input provided, nothing to do.")
|
|
297
|
+
print("Use 'batplot -h' for CLI help or 'batplot -m' to open the txt manual.")
|
|
298
|
+
return 0 # Exit successfully (not an error, just nothing to do)
|
|
299
|
+
|
|
300
|
+
# ====================================================================
|
|
301
|
+
# STEP 3: ROUTE TO APPROPRIATE MODE HANDLER
|
|
302
|
+
# ====================================================================
|
|
303
|
+
# Check flags in priority order and route to corresponding handler.
|
|
304
|
+
# Priority matters: some modes are checked before others.
|
|
305
|
+
# ====================================================================
|
|
306
|
+
|
|
307
|
+
# ====================================================================
|
|
308
|
+
# EC BATCH MODE (HIGHEST PRIORITY)
|
|
309
|
+
# ====================================================================
|
|
310
|
+
# If any EC mode is active AND user specified batch processing,
|
|
311
|
+
# route to EC batch handler (processes all EC files in directory).
|
|
312
|
+
#
|
|
313
|
+
# EC batch mode examples:
|
|
314
|
+
# batplot --gc --all --mass 7.0 → Process all .mpt/.csv files
|
|
315
|
+
# batplot --cv --all → Process all .mpt/.txt files
|
|
316
|
+
# batplot --gc all --mass 7.0 → Same as above (alternative syntax)
|
|
317
|
+
# batplot --gc /path/to/folder --mass 7 → Process specific directory
|
|
318
|
+
# ====================================================================
|
|
319
|
+
|
|
320
|
+
# Check if any EC mode is active
|
|
321
|
+
ec_mode_active = any([
|
|
322
|
+
getattr(args, 'gc', False), # Galvanostatic cycling
|
|
323
|
+
getattr(args, 'cv', False), # Cyclic voltammetry
|
|
324
|
+
getattr(args, 'dqdv', False), # Differential capacity
|
|
325
|
+
getattr(args, 'cpc', False) # Capacity per cycle
|
|
326
|
+
])
|
|
327
|
+
|
|
328
|
+
# Check for --all flag (explicit batch mode)
|
|
329
|
+
if ec_mode_active and getattr(args, 'all', None) is not None:
|
|
330
|
+
# Process all EC files in current directory
|
|
331
|
+
batch_process_ec(os.getcwd(), args)
|
|
332
|
+
exit() # Exit after batch processing (don't continue to other modes)
|
|
333
|
+
|
|
334
|
+
# Check for 'all' as file argument or directory path
|
|
335
|
+
if ec_mode_active and len(args.files) == 1:
|
|
336
|
+
sole = args.files[0]
|
|
337
|
+
if sole.lower() == 'all':
|
|
338
|
+
# User typed 'all' as file argument (alternative syntax)
|
|
339
|
+
batch_process_ec(os.getcwd(), args)
|
|
340
|
+
exit()
|
|
341
|
+
elif os.path.isdir(sole):
|
|
342
|
+
# User provided directory path
|
|
343
|
+
batch_process_ec(os.path.abspath(sole), args)
|
|
344
|
+
exit()
|
|
345
|
+
|
|
346
|
+
# --- CV mode: plot voltage vs current for each cycle from .mpt ---
|
|
347
|
+
if getattr(args, 'cv', False):
|
|
348
|
+
import os as _os
|
|
349
|
+
import matplotlib.pyplot as _plt
|
|
350
|
+
|
|
351
|
+
# Separate style files from data files
|
|
352
|
+
data_files = []
|
|
353
|
+
style_file_path = None
|
|
354
|
+
for f in args.files:
|
|
355
|
+
ext = os.path.splitext(f)[1].lower()
|
|
356
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
357
|
+
if style_file_path is None:
|
|
358
|
+
style_file_path = f
|
|
359
|
+
else:
|
|
360
|
+
print(f"Warning: Multiple style files provided, using first: {style_file_path}")
|
|
361
|
+
else:
|
|
362
|
+
data_files.append(f)
|
|
363
|
+
|
|
364
|
+
if not data_files:
|
|
365
|
+
print("CV mode: no data files found (only style files provided).")
|
|
366
|
+
exit(1)
|
|
367
|
+
|
|
368
|
+
# Load style file if provided
|
|
369
|
+
style_cfg = None
|
|
370
|
+
if style_file_path:
|
|
371
|
+
if not os.path.isfile(style_file_path):
|
|
372
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
373
|
+
else:
|
|
374
|
+
try:
|
|
375
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
376
|
+
style_cfg = json.load(f)
|
|
377
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
378
|
+
except Exception as e:
|
|
379
|
+
print(f"Warning: Could not load style file {style_file_path}: {e}")
|
|
380
|
+
|
|
381
|
+
# Process each data file
|
|
382
|
+
from .utils import ensure_subdirectory
|
|
383
|
+
out_dir = None
|
|
384
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
385
|
+
# Multiple files: create output directory
|
|
386
|
+
out_dir = ensure_subdirectory('Figures', os.getcwd())
|
|
387
|
+
|
|
388
|
+
for ec_file in data_files:
|
|
389
|
+
if not _os.path.isfile(ec_file):
|
|
390
|
+
print(f"File not found: {ec_file}")
|
|
391
|
+
continue
|
|
392
|
+
try:
|
|
393
|
+
# Support both .mpt and .txt formats
|
|
394
|
+
if ec_file.lower().endswith('.txt'):
|
|
395
|
+
from .readers import read_biologic_txt_file
|
|
396
|
+
voltage, current, cycles = read_biologic_txt_file(ec_file, mode='cv')
|
|
397
|
+
else:
|
|
398
|
+
voltage, current, cycles = read_mpt_file(ec_file, mode='cv')
|
|
399
|
+
# Normalize cycle indices to start at 1
|
|
400
|
+
# Find the first cycle with at least 2 data points (needed for plotting)
|
|
401
|
+
cyc_int_raw = np.array(np.rint(cycles), dtype=int)
|
|
402
|
+
if cyc_int_raw.size:
|
|
403
|
+
unique_cycles_raw = np.unique(cyc_int_raw)
|
|
404
|
+
valid_min_c = None
|
|
405
|
+
for c in sorted(unique_cycles_raw):
|
|
406
|
+
if np.sum(cyc_int_raw == c) >= 2:
|
|
407
|
+
valid_min_c = int(c)
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if valid_min_c is not None:
|
|
411
|
+
shift = 1 - valid_min_c
|
|
412
|
+
else:
|
|
413
|
+
min_c = int(np.min(cyc_int_raw))
|
|
414
|
+
shift = 1 - min_c if min_c <= 0 else 0
|
|
415
|
+
else:
|
|
416
|
+
shift = 0
|
|
417
|
+
cyc_int = cyc_int_raw + shift
|
|
418
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int)) if cyc_int.size else [1]
|
|
419
|
+
# Color palette
|
|
420
|
+
base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
421
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
422
|
+
# Ensure font and canvas settings match GC/dQdV
|
|
423
|
+
_plt.rcParams.update({
|
|
424
|
+
'font.family': 'sans-serif',
|
|
425
|
+
'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
|
|
426
|
+
'mathtext.fontset': 'dejavusans',
|
|
427
|
+
'font.size': 16
|
|
428
|
+
})
|
|
429
|
+
fig, ax = _plt.subplots(figsize=(10, 6))
|
|
430
|
+
cycle_lines = {}
|
|
431
|
+
for cyc in cycles_present:
|
|
432
|
+
mask = (cyc_int == cyc)
|
|
433
|
+
idx = np.where(mask)[0]
|
|
434
|
+
if idx.size >= 2:
|
|
435
|
+
# Insert NaNs between non-consecutive indices for proper cycle breaks
|
|
436
|
+
parts_x = []
|
|
437
|
+
parts_y = []
|
|
438
|
+
start = 0
|
|
439
|
+
for k in range(1, idx.size):
|
|
440
|
+
if idx[k] != idx[k-1] + 1:
|
|
441
|
+
parts_x.append(voltage[idx[start:k]])
|
|
442
|
+
parts_y.append(current[idx[start:k]])
|
|
443
|
+
start = k
|
|
444
|
+
parts_x.append(voltage[idx[start:]])
|
|
445
|
+
parts_y.append(current[idx[start:]])
|
|
446
|
+
X = []
|
|
447
|
+
Y = []
|
|
448
|
+
for i, (px, py) in enumerate(zip(parts_x, parts_y)):
|
|
449
|
+
if i > 0:
|
|
450
|
+
X.append(np.array([np.nan]))
|
|
451
|
+
Y.append(np.array([np.nan]))
|
|
452
|
+
X.append(px)
|
|
453
|
+
Y.append(py)
|
|
454
|
+
x_b = np.concatenate(X) if X else np.array([])
|
|
455
|
+
y_b = np.concatenate(Y) if Y else np.array([])
|
|
456
|
+
ln, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
457
|
+
linewidth=2.0, label=str(cyc), alpha=0.8)
|
|
458
|
+
cycle_lines[cyc] = ln
|
|
459
|
+
# Swap axis labels if --ro flag is set
|
|
460
|
+
if getattr(args, 'ro', False):
|
|
461
|
+
ax.set_xlabel('Current (mA)', labelpad=8.0)
|
|
462
|
+
ax.set_ylabel('Voltage (V)', labelpad=8.0)
|
|
463
|
+
else:
|
|
464
|
+
ax.set_xlabel('Voltage (V)', labelpad=8.0)
|
|
465
|
+
ax.set_ylabel('Current (mA)', labelpad=8.0)
|
|
466
|
+
legend = ax.legend(title='Cycle')
|
|
467
|
+
if legend is not None:
|
|
468
|
+
try:
|
|
469
|
+
legend.set_frame_on(False)
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
legend.get_title().set_fontsize('medium')
|
|
473
|
+
# Match GC/dQdV: consistent label/title displacement and canvas
|
|
474
|
+
fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
|
|
475
|
+
|
|
476
|
+
# Apply style file if provided
|
|
477
|
+
if style_cfg:
|
|
478
|
+
try:
|
|
479
|
+
from .batch import _apply_ec_style
|
|
480
|
+
_apply_ec_style(fig, ax, style_cfg)
|
|
481
|
+
# Redraw after applying style
|
|
482
|
+
if hasattr(fig, 'canvas'):
|
|
483
|
+
fig.canvas.draw()
|
|
484
|
+
except Exception as e:
|
|
485
|
+
print(f"Warning: Error applying style file: {e}")
|
|
486
|
+
|
|
487
|
+
# Save if requested
|
|
488
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
489
|
+
# Multiple files: save to Figures/ directory
|
|
490
|
+
base_name = os.path.splitext(os.path.basename(ec_file))[0]
|
|
491
|
+
output_format = getattr(args, 'format', 'svg')
|
|
492
|
+
outname = os.path.join(out_dir, f"{base_name}.{output_format}")
|
|
493
|
+
try:
|
|
494
|
+
_, _ext = _os.path.splitext(outname)
|
|
495
|
+
if _ext.lower() == '.svg':
|
|
496
|
+
_plt.rcParams['svg.fonttype'] = 'none'
|
|
497
|
+
_plt.rcParams['svg.hashsalt'] = None
|
|
498
|
+
fig.savefig(outname, dpi=300, transparent=True if _ext.lower() == '.svg' else False)
|
|
499
|
+
print(f"CV plot saved to {outname}")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
print(f"Warning: Could not save CV plot: {e}")
|
|
502
|
+
|
|
503
|
+
# Interactive menu: use electrochem_interactive_menu for consistency with GC
|
|
504
|
+
if args.interactive:
|
|
505
|
+
try:
|
|
506
|
+
_plt.ion()
|
|
507
|
+
except Exception:
|
|
508
|
+
pass
|
|
509
|
+
_plt.show(block=False)
|
|
510
|
+
try:
|
|
511
|
+
fig._bp_source_paths = [_os.path.abspath(ec_file)]
|
|
512
|
+
except Exception:
|
|
513
|
+
pass
|
|
514
|
+
try:
|
|
515
|
+
electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
|
|
516
|
+
except Exception as _ie:
|
|
517
|
+
print(f"Interactive menu failed: {_ie}")
|
|
518
|
+
_plt.show()
|
|
519
|
+
else:
|
|
520
|
+
if not (args.savefig or args.out):
|
|
521
|
+
_plt.show()
|
|
522
|
+
# For multiple files, close the figure and continue to next file
|
|
523
|
+
if len(data_files) > 1:
|
|
524
|
+
_plt.close(fig)
|
|
525
|
+
continue
|
|
526
|
+
else:
|
|
527
|
+
exit(0)
|
|
528
|
+
except Exception as e:
|
|
529
|
+
print(f"CV plot failed for {ec_file}: {e}")
|
|
530
|
+
if len(data_files) > 1:
|
|
531
|
+
continue
|
|
532
|
+
else:
|
|
533
|
+
exit(1)
|
|
534
|
+
# Exit after processing all files
|
|
535
|
+
if len(data_files) > 1:
|
|
536
|
+
print(f"Processed {len(data_files)} CV files.")
|
|
537
|
+
exit(0)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
"""
|
|
541
|
+
batplot_v1.0.10: Interactively plot:
|
|
542
|
+
XRD data .xye, .xy, .qye, .dat, .csv
|
|
543
|
+
PDF data .gr
|
|
544
|
+
XAS data .nor, .chik, .chir
|
|
545
|
+
More features to be added.
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# Ensure an interactive (GUI) backend when a window is expected (interactive mode or operando/GC plots)
|
|
550
|
+
def _ensure_gui_backend_for_interactive():
|
|
551
|
+
try:
|
|
552
|
+
argv = sys.argv
|
|
553
|
+
except Exception:
|
|
554
|
+
argv = []
|
|
555
|
+
# Trigger if interactive is requested OR when operando/GC plotting likely calls show()
|
|
556
|
+
wants_interactive = any(flag in argv for flag in ("--interactive",))
|
|
557
|
+
wants_interactive = wants_interactive or ("--operando" in argv)
|
|
558
|
+
wants_interactive = wants_interactive or ("--gc" in argv)
|
|
559
|
+
if not wants_interactive:
|
|
560
|
+
return
|
|
561
|
+
# If MPLBACKEND is set to a GUI backend, respect it; if it's non-interactive, we'll override below
|
|
562
|
+
env_be = os.environ.get("MPLBACKEND")
|
|
563
|
+
if env_be:
|
|
564
|
+
low = env_be.lower()
|
|
565
|
+
if low in {"macosx","tkagg","qtagg"}:
|
|
566
|
+
return
|
|
567
|
+
try:
|
|
568
|
+
be = _mpl.get_backend()
|
|
569
|
+
except Exception:
|
|
570
|
+
be = None
|
|
571
|
+
def _is_noninteractive(name):
|
|
572
|
+
if not isinstance(name, str):
|
|
573
|
+
return False
|
|
574
|
+
low = name.lower()
|
|
575
|
+
return ("agg" in low) or ("inline" in low) or (low in {"pdf","ps","svg","template"})
|
|
576
|
+
if not _is_noninteractive(be):
|
|
577
|
+
return
|
|
578
|
+
# Try GUI backends in order of likelihood
|
|
579
|
+
candidates = [
|
|
580
|
+
("darwin", ["MacOSX", "TkAgg", "QtAgg"]),
|
|
581
|
+
("win", ["TkAgg", "QtAgg"]),
|
|
582
|
+
("other", ["TkAgg", "QtAgg"]),
|
|
583
|
+
]
|
|
584
|
+
plat = sys.platform
|
|
585
|
+
if plat == "darwin":
|
|
586
|
+
order = candidates[0][1]
|
|
587
|
+
elif plat.startswith("win"):
|
|
588
|
+
order = candidates[1][1]
|
|
589
|
+
else:
|
|
590
|
+
order = candidates[2][1]
|
|
591
|
+
import importlib.util as _ilus
|
|
592
|
+
for cand in order:
|
|
593
|
+
try:
|
|
594
|
+
if cand == "TkAgg":
|
|
595
|
+
if _ilus.find_spec("tkinter") is None:
|
|
596
|
+
continue
|
|
597
|
+
elif cand == "QtAgg":
|
|
598
|
+
if (_ilus.find_spec("PyQt5") is None) and (_ilus.find_spec("PySide6") is None):
|
|
599
|
+
continue
|
|
600
|
+
# MacOSX: attempt; will fail on non-framework builds
|
|
601
|
+
_mpl.use(cand, force=True)
|
|
602
|
+
break
|
|
603
|
+
except Exception:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
_ensure_gui_backend_for_interactive()
|
|
607
|
+
|
|
608
|
+
import matplotlib.pyplot as plt
|
|
609
|
+
# Note: All imports moved to module level for clean import behavior
|
|
610
|
+
|
|
611
|
+
# Set global default font
|
|
612
|
+
plt.rcParams.update({
|
|
613
|
+
'font.family': 'sans-serif',
|
|
614
|
+
'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
|
|
615
|
+
'mathtext.fontset': 'dejavusans', # keeps math consistent with Arial-like sans
|
|
616
|
+
'font.size': 16
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
# Parse CLI arguments early; many top-level branches depend on args
|
|
620
|
+
args = _bp_parse_args()
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
"""
|
|
624
|
+
Note: CIF parsing and simulation helpers now come from batplot.cif.
|
|
625
|
+
This file defers to simulate_cif_pattern_Q and cif_reflection_positions
|
|
626
|
+
imported above to avoid duplicating heavy logic here.
|
|
627
|
+
"""
|
|
628
|
+
|
|
629
|
+
# ---------------- Conversion Function ----------------
|
|
630
|
+
# Implemented in batplot.converters as convert_to_qye
|
|
631
|
+
|
|
632
|
+
# Readers now live in batplot.readers; avoid duplicating implementations here.
|
|
633
|
+
|
|
634
|
+
# ---------------- .gr (Pair Distribution Function) Reading ----------------
|
|
635
|
+
|
|
636
|
+
# Label layout handled by plotting.update_labels imported at top.
|
|
637
|
+
|
|
638
|
+
#!/ End of legacy inline interactive_menu.
|
|
639
|
+
# Normal XY interactive menu is imported from batplot.interactive as `interactive_menu`.
|
|
640
|
+
|
|
641
|
+
# Galvanostatic cycling mode check: .mpt or supported .csv file with --gc flag
|
|
642
|
+
if getattr(args, 'gc', False):
|
|
643
|
+
import os as _os
|
|
644
|
+
import matplotlib.pyplot as _plt
|
|
645
|
+
|
|
646
|
+
# Separate style files from data files
|
|
647
|
+
data_files = []
|
|
648
|
+
style_file_path = None
|
|
649
|
+
for f in args.files:
|
|
650
|
+
ext = os.path.splitext(f)[1].lower()
|
|
651
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
652
|
+
if style_file_path is None:
|
|
653
|
+
style_file_path = f
|
|
654
|
+
else:
|
|
655
|
+
print(f"Warning: Multiple style files provided, using first: {style_file_path}")
|
|
656
|
+
else:
|
|
657
|
+
data_files.append(f)
|
|
658
|
+
|
|
659
|
+
if not data_files:
|
|
660
|
+
print("GC mode: no data files found (only style files provided).")
|
|
661
|
+
exit(1)
|
|
662
|
+
|
|
663
|
+
# Load style file if provided
|
|
664
|
+
style_cfg = None
|
|
665
|
+
if style_file_path:
|
|
666
|
+
if not os.path.isfile(style_file_path):
|
|
667
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
668
|
+
else:
|
|
669
|
+
try:
|
|
670
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
671
|
+
style_cfg = json.load(f)
|
|
672
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
673
|
+
except Exception as e:
|
|
674
|
+
print(f"Warning: Could not load style file {style_file_path}: {e}")
|
|
675
|
+
|
|
676
|
+
# Process each data file
|
|
677
|
+
from .utils import ensure_subdirectory
|
|
678
|
+
out_dir = None
|
|
679
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
680
|
+
# Multiple files: create output directory
|
|
681
|
+
out_dir = ensure_subdirectory('Figures', os.getcwd())
|
|
682
|
+
|
|
683
|
+
for ec_file_idx, ec_file in enumerate(data_files):
|
|
684
|
+
if not _os.path.isfile(ec_file):
|
|
685
|
+
print(f"File not found: {ec_file}")
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
# Branch by extension
|
|
690
|
+
if ec_file.lower().endswith('.mpt'):
|
|
691
|
+
# For .mpt, mass is required to compute specific capacity
|
|
692
|
+
mass_mg = getattr(args, 'mass', None)
|
|
693
|
+
if mass_mg is None:
|
|
694
|
+
print("GC mode (.mpt): --mass parameter is required (active material mass in milligrams).")
|
|
695
|
+
print("Example: batplot file.mpt --gc --mass 7.0")
|
|
696
|
+
if len(data_files) > 1:
|
|
697
|
+
continue
|
|
698
|
+
else:
|
|
699
|
+
exit(1)
|
|
700
|
+
specific_capacity, voltage, cycle_numbers, charge_mask, discharge_mask = read_mpt_file(ec_file, mode='gc', mass_mg=mass_mg)
|
|
701
|
+
x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
|
|
702
|
+
cap_x = specific_capacity
|
|
703
|
+
elif ec_file.lower().endswith('.csv'):
|
|
704
|
+
# Check if this is CS-B format
|
|
705
|
+
try:
|
|
706
|
+
header, _, _ = _load_csv_header_and_rows(ec_file)
|
|
707
|
+
if is_cs_b_format(header):
|
|
708
|
+
# Use CS-B format reader
|
|
709
|
+
cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_cs_b_csv_file(ec_file, mode='gc')
|
|
710
|
+
else:
|
|
711
|
+
# Use standard CSV reader
|
|
712
|
+
cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_ec_csv_file(ec_file, prefer_specific=True)
|
|
713
|
+
except Exception:
|
|
714
|
+
# Fallback to standard reader
|
|
715
|
+
cap_x, voltage, cycle_numbers, charge_mask, discharge_mask = read_ec_csv_file(ec_file, prefer_specific=True)
|
|
716
|
+
x_label_gc = r'Specific Capacity (mAh g$^{-1}$)'
|
|
717
|
+
else:
|
|
718
|
+
print(f"GC mode: file must be .mpt or .csv: {ec_file}")
|
|
719
|
+
if len(data_files) > 1:
|
|
720
|
+
continue
|
|
721
|
+
else:
|
|
722
|
+
exit(1)
|
|
723
|
+
|
|
724
|
+
# Create the plot
|
|
725
|
+
fig, ax = _plt.subplots(figsize=(10, 6))
|
|
726
|
+
|
|
727
|
+
# Build per-cycle lines for charge and discharge
|
|
728
|
+
def _contiguous_blocks(mask):
|
|
729
|
+
inds = np.where(mask)[0]
|
|
730
|
+
if inds.size == 0:
|
|
731
|
+
return []
|
|
732
|
+
blocks = []
|
|
733
|
+
start = inds[0]
|
|
734
|
+
prev = inds[0]
|
|
735
|
+
for j in inds[1:]:
|
|
736
|
+
if j == prev + 1:
|
|
737
|
+
prev = j
|
|
738
|
+
else:
|
|
739
|
+
blocks.append((start, prev))
|
|
740
|
+
start = j
|
|
741
|
+
prev = j
|
|
742
|
+
blocks.append((start, prev))
|
|
743
|
+
return blocks
|
|
744
|
+
|
|
745
|
+
def _broken_arrays_from_indices(idx: np.ndarray, x: np.ndarray, y: np.ndarray):
|
|
746
|
+
"""Insert NaNs between non-consecutive indices so a single Line2D can represent disjoint segments."""
|
|
747
|
+
if idx.size == 0:
|
|
748
|
+
return np.array([]), np.array([])
|
|
749
|
+
parts_x = []
|
|
750
|
+
parts_y = []
|
|
751
|
+
start = 0
|
|
752
|
+
for k in range(1, idx.size):
|
|
753
|
+
if idx[k] != idx[k-1] + 1:
|
|
754
|
+
parts_x.append(x[idx[start:k]])
|
|
755
|
+
parts_y.append(y[idx[start:k]])
|
|
756
|
+
start = k
|
|
757
|
+
parts_x.append(x[idx[start:]])
|
|
758
|
+
parts_y.append(y[idx[start:]])
|
|
759
|
+
# Concatenate with NaN separators
|
|
760
|
+
X = []
|
|
761
|
+
Y = []
|
|
762
|
+
for i, (px, py) in enumerate(zip(parts_x, parts_y)):
|
|
763
|
+
if i > 0:
|
|
764
|
+
X.append(np.array([np.nan]))
|
|
765
|
+
Y.append(np.array([np.nan]))
|
|
766
|
+
X.append(px)
|
|
767
|
+
Y.append(py)
|
|
768
|
+
return np.concatenate(X) if X else np.array([]), np.concatenate(Y) if Y else np.array([])
|
|
769
|
+
|
|
770
|
+
if cycle_numbers is not None:
|
|
771
|
+
# Normalize cycle indices to start at 1 (BioLogic may start at 0)
|
|
772
|
+
# But first, identify cycles with sufficient data (>= 2 points) to be plotted
|
|
773
|
+
cyc_int_raw = np.array(np.rint(cycle_numbers), dtype=int)
|
|
774
|
+
if cyc_int_raw.size:
|
|
775
|
+
# Find the minimum cycle number that has at least 2 data points
|
|
776
|
+
unique_cycles_raw = np.unique(cyc_int_raw)
|
|
777
|
+
valid_min_c = None
|
|
778
|
+
for c in sorted(unique_cycles_raw):
|
|
779
|
+
if np.sum(cyc_int_raw == c) >= 2:
|
|
780
|
+
valid_min_c = int(c)
|
|
781
|
+
break
|
|
782
|
+
|
|
783
|
+
if valid_min_c is not None:
|
|
784
|
+
# Shift so the first valid cycle becomes cycle 1
|
|
785
|
+
shift = 1 - valid_min_c
|
|
786
|
+
else:
|
|
787
|
+
# No valid cycles found, use original min
|
|
788
|
+
min_c = int(np.min(cyc_int_raw))
|
|
789
|
+
shift = 1 - min_c if min_c <= 0 else 0
|
|
790
|
+
else:
|
|
791
|
+
shift = 0
|
|
792
|
+
|
|
793
|
+
cyc_int = cyc_int_raw + shift
|
|
794
|
+
cycles_present = sorted(int(c) for c in np.unique(cyc_int))
|
|
795
|
+
else:
|
|
796
|
+
cycles_present = [1]
|
|
797
|
+
|
|
798
|
+
# Determine if cycle numbers are meaningful
|
|
799
|
+
inferred = len(cycles_present) <= 1
|
|
800
|
+
if inferred:
|
|
801
|
+
ch_blocks = _contiguous_blocks(charge_mask)
|
|
802
|
+
dch_blocks = _contiguous_blocks(discharge_mask)
|
|
803
|
+
cycles_present = list(range(1, max(len(ch_blocks), len(dch_blocks)) + 1)) if (ch_blocks or dch_blocks) else [1]
|
|
804
|
+
|
|
805
|
+
# Prepare colors
|
|
806
|
+
base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
807
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
808
|
+
|
|
809
|
+
# Mapping: cycle_number -> {'charge': Line2D|None, 'discharge': Line2D|None}
|
|
810
|
+
cycle_lines = {}
|
|
811
|
+
|
|
812
|
+
if not inferred and cycle_numbers is not None:
|
|
813
|
+
for cyc in cycles_present:
|
|
814
|
+
# Charge
|
|
815
|
+
mask_c = (cyc_int == cyc) & charge_mask
|
|
816
|
+
idx = np.where(mask_c)[0]
|
|
817
|
+
if idx.size >= 2:
|
|
818
|
+
x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
|
|
819
|
+
# Label only once per cycle for legend: Cycle N
|
|
820
|
+
# Swap x and y if --ro flag is set
|
|
821
|
+
if getattr(args, 'ro', False):
|
|
822
|
+
ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
823
|
+
linewidth=2.0, label=str(cyc), alpha=0.8)
|
|
824
|
+
else:
|
|
825
|
+
|
|
826
|
+
ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
827
|
+
linewidth=2.0, label=str(cyc), alpha=0.8)
|
|
828
|
+
else:
|
|
829
|
+
ln_c = None
|
|
830
|
+
# Discharge
|
|
831
|
+
mask_d = (cyc_int == cyc) & discharge_mask
|
|
832
|
+
idxd = np.where(mask_d)[0]
|
|
833
|
+
if idxd.size >= 2:
|
|
834
|
+
xd_b, yd_b = _broken_arrays_from_indices(idxd, cap_x, voltage)
|
|
835
|
+
# Use no legend entry for the second line of the same cycle
|
|
836
|
+
lbl = '_nolegend_' if ln_c is not None else str(cyc)
|
|
837
|
+
# Swap x and y if --ro flag is set
|
|
838
|
+
if getattr(args, 'ro', False):
|
|
839
|
+
ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
840
|
+
linewidth=2.0, label=lbl, alpha=0.8)
|
|
841
|
+
else:
|
|
842
|
+
|
|
843
|
+
ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
844
|
+
linewidth=2.0, label=lbl, alpha=0.8)
|
|
845
|
+
else:
|
|
846
|
+
ln_d = None
|
|
847
|
+
cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
|
|
848
|
+
else:
|
|
849
|
+
# Infer cycles by alternating contiguous charge/discharge blocks
|
|
850
|
+
ch_blocks = _contiguous_blocks(charge_mask)
|
|
851
|
+
dch_blocks = _contiguous_blocks(discharge_mask)
|
|
852
|
+
N = max(len(ch_blocks), len(dch_blocks))
|
|
853
|
+
for i in range(N):
|
|
854
|
+
cyc = i + 1
|
|
855
|
+
ln_c = None
|
|
856
|
+
if i < len(ch_blocks):
|
|
857
|
+
a, b = ch_blocks[i]
|
|
858
|
+
idx = np.arange(a, b + 1)
|
|
859
|
+
x_b, y_b = _broken_arrays_from_indices(idx, cap_x, voltage)
|
|
860
|
+
# Swap x and y if --ro flag is set
|
|
861
|
+
if getattr(args, 'ro', False):
|
|
862
|
+
ln_c, = ax.plot(y_b, x_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
863
|
+
linewidth=2.0, label=str(cyc), alpha=0.8)
|
|
864
|
+
else:
|
|
865
|
+
|
|
866
|
+
ln_c, = ax.plot(x_b, y_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
867
|
+
linewidth=2.0, label=str(cyc), alpha=0.8)
|
|
868
|
+
ln_d = None
|
|
869
|
+
if i < len(dch_blocks):
|
|
870
|
+
a, b = dch_blocks[i]
|
|
871
|
+
idx = np.arange(a, b + 1)
|
|
872
|
+
xd_b, yd_b = _broken_arrays_from_indices(idx, cap_x, voltage)
|
|
873
|
+
lbl = '_nolegend_' if ln_c is not None else str(cyc)
|
|
874
|
+
# Swap x and y if --ro flag is set
|
|
875
|
+
if getattr(args, 'ro', False):
|
|
876
|
+
ln_d, = ax.plot(yd_b, xd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
877
|
+
linewidth=2.0, label=lbl, alpha=0.8)
|
|
878
|
+
else:
|
|
879
|
+
|
|
880
|
+
ln_d, = ax.plot(xd_b, yd_b, '-', color=base_colors[(cyc-1) % len(base_colors)],
|
|
881
|
+
linewidth=2.0, label=lbl, alpha=0.8)
|
|
882
|
+
cycle_lines[cyc] = {"charge": ln_c, "discharge": ln_d}
|
|
883
|
+
# Labels with consistent labelpad
|
|
884
|
+
# Swap axis labels if --ro flag is set
|
|
885
|
+
if getattr(args, 'ro', False):
|
|
886
|
+
ax.set_xlabel('Voltage (V)', labelpad=8.0)
|
|
887
|
+
ax.set_ylabel(x_label_gc, labelpad=8.0)
|
|
888
|
+
else:
|
|
889
|
+
ax.set_xlabel(x_label_gc, labelpad=8.0)
|
|
890
|
+
ax.set_ylabel('Voltage (V)', labelpad=8.0)
|
|
891
|
+
legend = ax.legend(title='Cycle')
|
|
892
|
+
if legend is not None:
|
|
893
|
+
try:
|
|
894
|
+
legend.set_frame_on(False)
|
|
895
|
+
except Exception:
|
|
896
|
+
pass
|
|
897
|
+
legend.get_title().set_fontsize('medium')
|
|
898
|
+
# No background grid by default for GC plots
|
|
899
|
+
|
|
900
|
+
# Adjust layout to ensure top and bottom labels/titles are visible
|
|
901
|
+
fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
|
|
902
|
+
|
|
903
|
+
# Apply style file if provided
|
|
904
|
+
if style_cfg:
|
|
905
|
+
try:
|
|
906
|
+
from .batch import _apply_ec_style
|
|
907
|
+
_apply_ec_style(fig, ax, style_cfg)
|
|
908
|
+
# Redraw after applying style
|
|
909
|
+
fig.canvas.draw() if hasattr(fig, 'canvas') else None
|
|
910
|
+
except Exception as e:
|
|
911
|
+
print(f"Warning: Error applying style file: {e}")
|
|
912
|
+
|
|
913
|
+
# Save if requested
|
|
914
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
915
|
+
# Multiple files: save to Figures/ directory
|
|
916
|
+
base_name = os.path.splitext(os.path.basename(ec_file))[0]
|
|
917
|
+
output_format = getattr(args, 'format', 'svg')
|
|
918
|
+
outname = os.path.join(out_dir, f"{base_name}.{output_format}")
|
|
919
|
+
else:
|
|
920
|
+
outname = args.savefig or args.out
|
|
921
|
+
if outname:
|
|
922
|
+
if not _os.path.splitext(outname)[1]:
|
|
923
|
+
outname += '.svg'
|
|
924
|
+
# Transparent background for SVG exports
|
|
925
|
+
_, _ext = _os.path.splitext(outname)
|
|
926
|
+
if _ext.lower() == '.svg':
|
|
927
|
+
# Fix for Affinity Designer/Photo compatibility issues
|
|
928
|
+
# Use 'none' to embed fonts as text (not paths) - prevents phantom labels
|
|
929
|
+
# Set hashsalt to empty to avoid duplicate text elements
|
|
930
|
+
_plt.rcParams['svg.fonttype'] = 'none'
|
|
931
|
+
_plt.rcParams['svg.hashsalt'] = None
|
|
932
|
+
try:
|
|
933
|
+
_fig_fc = fig.get_facecolor()
|
|
934
|
+
except Exception:
|
|
935
|
+
_fig_fc = None
|
|
936
|
+
try:
|
|
937
|
+
_ax_fc = ax.get_facecolor()
|
|
938
|
+
except Exception:
|
|
939
|
+
_ax_fc = None
|
|
940
|
+
try:
|
|
941
|
+
if getattr(fig, 'patch', None) is not None:
|
|
942
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
943
|
+
if getattr(ax, 'patch', None) is not None:
|
|
944
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
945
|
+
except Exception:
|
|
946
|
+
pass
|
|
947
|
+
try:
|
|
948
|
+
fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
949
|
+
finally:
|
|
950
|
+
try:
|
|
951
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
952
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
953
|
+
except Exception:
|
|
954
|
+
pass
|
|
955
|
+
try:
|
|
956
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
957
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
958
|
+
except Exception:
|
|
959
|
+
pass
|
|
960
|
+
else:
|
|
961
|
+
fig.savefig(outname, dpi=300)
|
|
962
|
+
print(f"GC plot saved to {outname} ({x_label_gc})")
|
|
963
|
+
|
|
964
|
+
# Show plot / interactive menu
|
|
965
|
+
if args.interactive:
|
|
966
|
+
# Guard against non-interactive backends (e.g., Agg)
|
|
967
|
+
try:
|
|
968
|
+
_backend = _plt.get_backend()
|
|
969
|
+
except Exception:
|
|
970
|
+
_backend = "unknown"
|
|
971
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
972
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
973
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
974
|
+
if _is_noninteractive:
|
|
975
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
|
|
976
|
+
print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
|
|
977
|
+
print(" export MPLBACKEND=MacOSX # built-in macOS backend")
|
|
978
|
+
print(" export MPLBACKEND=TkAgg # if Tk is available")
|
|
979
|
+
print(" export MPLBACKEND=QtAgg # if PyQt is installed")
|
|
980
|
+
print("Or run without --interactive and use --out to save the figure.")
|
|
981
|
+
else:
|
|
982
|
+
# Turn on interactive mode and show non-blocking window
|
|
983
|
+
try:
|
|
984
|
+
_plt.ion()
|
|
985
|
+
except Exception:
|
|
986
|
+
pass
|
|
987
|
+
_plt.show(block=False)
|
|
988
|
+
try:
|
|
989
|
+
fig._bp_source_paths = [_os.path.abspath(ec_file)]
|
|
990
|
+
except Exception:
|
|
991
|
+
pass
|
|
992
|
+
try:
|
|
993
|
+
electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
|
|
994
|
+
except Exception as _ie:
|
|
995
|
+
print(f"Interactive menu failed: {_ie}")
|
|
996
|
+
# Keep window open after menu
|
|
997
|
+
_plt.show()
|
|
998
|
+
else:
|
|
999
|
+
if not (args.savefig or args.out):
|
|
1000
|
+
# Only show when a GUI backend is available
|
|
1001
|
+
try:
|
|
1002
|
+
_backend = _plt.get_backend()
|
|
1003
|
+
except Exception:
|
|
1004
|
+
_backend = "unknown"
|
|
1005
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1006
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1007
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1008
|
+
if not _is_noninteractive:
|
|
1009
|
+
_plt.show()
|
|
1010
|
+
else:
|
|
1011
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
|
|
1012
|
+
# For multiple files, close the figure and continue to next file
|
|
1013
|
+
if len(data_files) > 1:
|
|
1014
|
+
_plt.close(fig)
|
|
1015
|
+
continue
|
|
1016
|
+
else:
|
|
1017
|
+
exit()
|
|
1018
|
+
except Exception as _e:
|
|
1019
|
+
print(f"GC plot failed for {ec_file}: {_e}")
|
|
1020
|
+
if len(data_files) > 1:
|
|
1021
|
+
continue
|
|
1022
|
+
else:
|
|
1023
|
+
exit(1)
|
|
1024
|
+
# Exit after processing all files
|
|
1025
|
+
if len(data_files) > 1:
|
|
1026
|
+
print(f"Processed {len(data_files)} GC files.")
|
|
1027
|
+
exit()
|
|
1028
|
+
|
|
1029
|
+
# Capacity-per-cycle (CPC) summary from CSV or .mpt with coulombic efficiency
|
|
1030
|
+
if getattr(args, 'cpc', False):
|
|
1031
|
+
import os as _os
|
|
1032
|
+
import numpy as _np
|
|
1033
|
+
|
|
1034
|
+
# Separate style files from data files
|
|
1035
|
+
data_files = []
|
|
1036
|
+
style_file_path = None
|
|
1037
|
+
for f in args.files:
|
|
1038
|
+
ext = os.path.splitext(f)[1].lower()
|
|
1039
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
1040
|
+
if style_file_path is None:
|
|
1041
|
+
style_file_path = f
|
|
1042
|
+
else:
|
|
1043
|
+
print(f"Warning: Multiple style files provided, using first: {style_file_path}")
|
|
1044
|
+
else:
|
|
1045
|
+
data_files.append(f)
|
|
1046
|
+
|
|
1047
|
+
if len(data_files) < 1:
|
|
1048
|
+
print("CPC mode: provide at least one file (.csv, .xlsx, or .mpt).")
|
|
1049
|
+
exit(1)
|
|
1050
|
+
|
|
1051
|
+
# Load style file if provided
|
|
1052
|
+
style_cfg = None
|
|
1053
|
+
if style_file_path:
|
|
1054
|
+
if not os.path.isfile(style_file_path):
|
|
1055
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
1056
|
+
else:
|
|
1057
|
+
try:
|
|
1058
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
1059
|
+
style_cfg = json.load(f)
|
|
1060
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
1061
|
+
except Exception as e:
|
|
1062
|
+
print(f"Warning: Could not load style file {style_file_path}: {e}")
|
|
1063
|
+
|
|
1064
|
+
# Process multiple files
|
|
1065
|
+
file_data = [] # List of dicts with file info and data
|
|
1066
|
+
# Use tab10 for capacity and viridis for efficiency
|
|
1067
|
+
import matplotlib.cm as cm
|
|
1068
|
+
import matplotlib.colors as mcolors
|
|
1069
|
+
n_files = len(data_files)
|
|
1070
|
+
|
|
1071
|
+
# Use tab10 hardcoded colors for capacity (matching interactive menu)
|
|
1072
|
+
default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
1073
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
1074
|
+
if n_files <= 1:
|
|
1075
|
+
capacity_colors = [default_tab10_colors[0]]
|
|
1076
|
+
eff_positions = [0.55]
|
|
1077
|
+
else:
|
|
1078
|
+
capacity_colors = [default_tab10_colors[i % len(default_tab10_colors)] for i in range(n_files)]
|
|
1079
|
+
eff_positions = np.linspace(0.08, 0.88, n_files)
|
|
1080
|
+
|
|
1081
|
+
# Use viridis for efficiency
|
|
1082
|
+
efficiency_cmap = cm.get_cmap('viridis')
|
|
1083
|
+
efficiency_colors = [mcolors.rgb2hex(efficiency_cmap(pos)[:3]) for pos in eff_positions]
|
|
1084
|
+
|
|
1085
|
+
for file_idx, ec_file in enumerate(data_files):
|
|
1086
|
+
if not _os.path.isfile(ec_file):
|
|
1087
|
+
print(f"File not found: {ec_file}")
|
|
1088
|
+
continue
|
|
1089
|
+
|
|
1090
|
+
ext = _os.path.splitext(ec_file)[1].lower()
|
|
1091
|
+
file_basename = _os.path.basename(ec_file)
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
if ext in ['.csv', '.xlsx', '.xls']:
|
|
1095
|
+
# Check if this is CS-B format
|
|
1096
|
+
try:
|
|
1097
|
+
header, _, _ = _load_csv_header_and_rows(ec_file)
|
|
1098
|
+
if is_cs_b_format(header):
|
|
1099
|
+
# Use CS-B format reader for CPC
|
|
1100
|
+
cyc_nums, cap_charge, cap_discharge, eff = read_cs_b_csv_file(ec_file, mode='cpc')
|
|
1101
|
+
else:
|
|
1102
|
+
# Use standard CSV reader
|
|
1103
|
+
cap_x, voltage, cycles, chg_mask, dchg_mask = read_ec_csv_file(ec_file, prefer_specific=True)
|
|
1104
|
+
cyc = _np.array(cycles, dtype=int)
|
|
1105
|
+
unique_cycles = _np.unique(cyc)
|
|
1106
|
+
unique_cycles = unique_cycles[_np.isfinite(unique_cycles)]
|
|
1107
|
+
unique_cycles = [int(x) for x in unique_cycles]
|
|
1108
|
+
if not unique_cycles:
|
|
1109
|
+
unique_cycles = [1]
|
|
1110
|
+
cyc_nums = []
|
|
1111
|
+
cap_charge = []
|
|
1112
|
+
cap_discharge = []
|
|
1113
|
+
eff = []
|
|
1114
|
+
for c in sorted(unique_cycles):
|
|
1115
|
+
m_c = (cyc == c)
|
|
1116
|
+
qchg = _np.nanmax(cap_x[m_c & chg_mask]) if _np.any(m_c & chg_mask) else _np.nan
|
|
1117
|
+
qdch = _np.nanmax(cap_x[m_c & dchg_mask]) if _np.any(m_c & dchg_mask) else _np.nan
|
|
1118
|
+
eta = (qdch / qchg * 100.0) if (_np.isfinite(qchg) and qchg > 0 and _np.isfinite(qdch)) else _np.nan
|
|
1119
|
+
cyc_nums.append(c)
|
|
1120
|
+
cap_charge.append(qchg)
|
|
1121
|
+
cap_discharge.append(qdch)
|
|
1122
|
+
eff.append(eta)
|
|
1123
|
+
cyc_nums = _np.array(cyc_nums, dtype=float)
|
|
1124
|
+
cap_charge = _np.array(cap_charge, dtype=float)
|
|
1125
|
+
cap_discharge = _np.array(cap_discharge, dtype=float)
|
|
1126
|
+
eff = _np.array(eff, dtype=float)
|
|
1127
|
+
except Exception as e:
|
|
1128
|
+
# Fallback to standard reader
|
|
1129
|
+
cap_x, voltage, cycles, chg_mask, dchg_mask = read_ec_csv_file(ec_file, prefer_specific=True)
|
|
1130
|
+
cyc = _np.array(cycles, dtype=int)
|
|
1131
|
+
unique_cycles = _np.unique(cyc)
|
|
1132
|
+
unique_cycles = unique_cycles[_np.isfinite(unique_cycles)]
|
|
1133
|
+
unique_cycles = [int(x) for x in unique_cycles]
|
|
1134
|
+
if not unique_cycles:
|
|
1135
|
+
unique_cycles = [1]
|
|
1136
|
+
cyc_nums = []
|
|
1137
|
+
cap_charge = []
|
|
1138
|
+
cap_discharge = []
|
|
1139
|
+
eff = []
|
|
1140
|
+
for c in sorted(unique_cycles):
|
|
1141
|
+
m_c = (cyc == c)
|
|
1142
|
+
qchg = _np.nanmax(cap_x[m_c & chg_mask]) if _np.any(m_c & chg_mask) else _np.nan
|
|
1143
|
+
qdch = _np.nanmax(cap_x[m_c & dchg_mask]) if _np.any(m_c & dchg_mask) else _np.nan
|
|
1144
|
+
eta = (qdch / qchg * 100.0) if (_np.isfinite(qchg) and qchg > 0 and _np.isfinite(qdch)) else _np.nan
|
|
1145
|
+
cyc_nums.append(c)
|
|
1146
|
+
cap_charge.append(qchg)
|
|
1147
|
+
cap_discharge.append(qdch)
|
|
1148
|
+
eff.append(eta)
|
|
1149
|
+
cyc_nums = _np.array(cyc_nums, dtype=float)
|
|
1150
|
+
cap_charge = _np.array(cap_charge, dtype=float)
|
|
1151
|
+
cap_discharge = _np.array(cap_discharge, dtype=float)
|
|
1152
|
+
eff = _np.array(eff, dtype=float)
|
|
1153
|
+
elif ext == '.mpt':
|
|
1154
|
+
mass_mg = getattr(args, 'mass', None)
|
|
1155
|
+
if mass_mg is None:
|
|
1156
|
+
print(f"Skipped {file_basename}: CPC mode (.mpt) requires --mass parameter.")
|
|
1157
|
+
continue
|
|
1158
|
+
cyc_nums, cap_charge, cap_discharge, eff = read_mpt_file(ec_file, mode='cpc', mass_mg=mass_mg)
|
|
1159
|
+
else:
|
|
1160
|
+
print(f"Skipped {file_basename}: unsupported format (must be .csv, .xlsx, or .mpt)")
|
|
1161
|
+
continue
|
|
1162
|
+
|
|
1163
|
+
# Assign colors: distinct hue for each file
|
|
1164
|
+
capacity_color = capacity_colors[file_idx % len(capacity_colors)]
|
|
1165
|
+
efficiency_color = efficiency_colors[file_idx % len(efficiency_colors)]
|
|
1166
|
+
# Generate discharge color using the same function as interactive menu
|
|
1167
|
+
discharge_color = _generate_similar_color(capacity_color)
|
|
1168
|
+
|
|
1169
|
+
file_data.append({
|
|
1170
|
+
'filename': file_basename,
|
|
1171
|
+
'filepath': ec_file,
|
|
1172
|
+
'cyc_nums': cyc_nums,
|
|
1173
|
+
'cap_charge': cap_charge,
|
|
1174
|
+
'cap_discharge': cap_discharge,
|
|
1175
|
+
'eff': eff,
|
|
1176
|
+
'color': capacity_color,
|
|
1177
|
+
'discharge_color': discharge_color,
|
|
1178
|
+
'eff_color': efficiency_color,
|
|
1179
|
+
'visible': True
|
|
1180
|
+
})
|
|
1181
|
+
|
|
1182
|
+
except Exception as e:
|
|
1183
|
+
print(f"Failed to read {file_basename}: {e}")
|
|
1184
|
+
continue
|
|
1185
|
+
|
|
1186
|
+
if not file_data:
|
|
1187
|
+
print("No valid CPC data files to plot.")
|
|
1188
|
+
exit(1)
|
|
1189
|
+
|
|
1190
|
+
# Plot (same figsize as GC)
|
|
1191
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
1192
|
+
ax.set_xlabel('Cycle number', labelpad=8.0)
|
|
1193
|
+
ax.set_ylabel(r'Specific Capacity (mAh g$^{-1}$)', labelpad=8.0)
|
|
1194
|
+
ax.grid(True, alpha=0.25, linestyle='--', linewidth=0.8)
|
|
1195
|
+
|
|
1196
|
+
ax2 = ax.twinx()
|
|
1197
|
+
ax2.set_ylabel('Efficiency (%)', labelpad=8.0)
|
|
1198
|
+
|
|
1199
|
+
# Create scatter plots for each file
|
|
1200
|
+
for file_info in file_data:
|
|
1201
|
+
cyc_nums = file_info['cyc_nums']
|
|
1202
|
+
cap_charge = file_info['cap_charge']
|
|
1203
|
+
cap_discharge = file_info['cap_discharge']
|
|
1204
|
+
eff = file_info['eff']
|
|
1205
|
+
color = file_info['color'] # Warm color for capacity
|
|
1206
|
+
eff_color = file_info['eff_color'] # Cold color for efficiency
|
|
1207
|
+
label = file_info['filename']
|
|
1208
|
+
|
|
1209
|
+
# For single file, use simple labels; for multiple files, prefix with filename
|
|
1210
|
+
if len(file_data) == 1:
|
|
1211
|
+
label_chg = 'Charge capacity'
|
|
1212
|
+
label_dch = 'Discharge capacity'
|
|
1213
|
+
label_eff = 'Coulombic efficiency'
|
|
1214
|
+
else:
|
|
1215
|
+
label_chg = f'{label} (Chg)'
|
|
1216
|
+
label_dch = f'{label} (Dch)'
|
|
1217
|
+
label_eff = f'{label} (Eff)'
|
|
1218
|
+
|
|
1219
|
+
# Use stored discharge color if available, otherwise generate it
|
|
1220
|
+
if 'discharge_color' in file_info and file_info['discharge_color'] is not None:
|
|
1221
|
+
discharge_color = file_info['discharge_color']
|
|
1222
|
+
else:
|
|
1223
|
+
discharge_color = _generate_similar_color(color)
|
|
1224
|
+
|
|
1225
|
+
sc_charge = ax.scatter(cyc_nums, cap_charge, color=color, label=label_chg,
|
|
1226
|
+
s=32, zorder=3, alpha=0.8, marker='o')
|
|
1227
|
+
sc_discharge = ax.scatter(cyc_nums, cap_discharge, color=discharge_color, label=label_dch,
|
|
1228
|
+
s=32, zorder=3, alpha=0.8, marker='s')
|
|
1229
|
+
sc_eff = ax2.scatter(cyc_nums, eff, color=eff_color, marker='^', label=label_eff,
|
|
1230
|
+
s=40, alpha=0.7, zorder=3)
|
|
1231
|
+
|
|
1232
|
+
# Store scatter artists in file_info for interactive menu
|
|
1233
|
+
file_info['sc_charge'] = sc_charge
|
|
1234
|
+
file_info['sc_discharge'] = sc_discharge
|
|
1235
|
+
file_info['sc_eff'] = sc_eff
|
|
1236
|
+
|
|
1237
|
+
# Set efficiency y-range to 0-120 by default
|
|
1238
|
+
ax2.set_ylim(0, 120)
|
|
1239
|
+
|
|
1240
|
+
# Compose a combined legend
|
|
1241
|
+
try:
|
|
1242
|
+
h1, l1 = ax.get_legend_handles_labels()
|
|
1243
|
+
h2, l2 = ax2.get_legend_handles_labels()
|
|
1244
|
+
combined_handles = h1 + h2
|
|
1245
|
+
leg = ax.legend(
|
|
1246
|
+
combined_handles, l1 + l2,
|
|
1247
|
+
loc='best',
|
|
1248
|
+
frameon=False,
|
|
1249
|
+
handlelength=1.0,
|
|
1250
|
+
handletextpad=0.35,
|
|
1251
|
+
labelspacing=0.25,
|
|
1252
|
+
borderaxespad=0.5,
|
|
1253
|
+
borderpad=0.3,
|
|
1254
|
+
columnspacing=0.6,
|
|
1255
|
+
labelcolor='linecolor',
|
|
1256
|
+
)
|
|
1257
|
+
if leg is not None:
|
|
1258
|
+
try:
|
|
1259
|
+
leg.set_frame_on(False)
|
|
1260
|
+
leg.set_edgecolor('none')
|
|
1261
|
+
leg.set_facecolor('none')
|
|
1262
|
+
except Exception:
|
|
1263
|
+
pass
|
|
1264
|
+
except Exception:
|
|
1265
|
+
pass
|
|
1266
|
+
|
|
1267
|
+
# Adjust layout to ensure top and bottom labels/titles are visible
|
|
1268
|
+
fig.subplots_adjust(left=0.12, right=0.88, top=0.88, bottom=0.15)
|
|
1269
|
+
|
|
1270
|
+
# Check for style file in file list
|
|
1271
|
+
style_file_path = None
|
|
1272
|
+
for f in args.files:
|
|
1273
|
+
ext = os.path.splitext(f)[1].lower()
|
|
1274
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
1275
|
+
style_file_path = f
|
|
1276
|
+
break
|
|
1277
|
+
|
|
1278
|
+
# Load and apply style file if provided
|
|
1279
|
+
if style_file_path:
|
|
1280
|
+
if os.path.isfile(style_file_path):
|
|
1281
|
+
try:
|
|
1282
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
1283
|
+
style_cfg = json.load(f)
|
|
1284
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
1285
|
+
from .batch import _apply_ec_style
|
|
1286
|
+
_apply_ec_style(fig, ax, style_cfg)
|
|
1287
|
+
# Also apply to twin axis
|
|
1288
|
+
_apply_ec_style(fig, ax2, style_cfg)
|
|
1289
|
+
# Redraw after applying style
|
|
1290
|
+
if hasattr(fig, 'canvas'):
|
|
1291
|
+
fig.canvas.draw()
|
|
1292
|
+
except Exception as e:
|
|
1293
|
+
print(f"Warning: Error applying style file: {e}")
|
|
1294
|
+
else:
|
|
1295
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
1296
|
+
|
|
1297
|
+
if args.interactive and cpc_interactive_menu is not None:
|
|
1298
|
+
# Guard against non-interactive backends (e.g., Agg)
|
|
1299
|
+
try:
|
|
1300
|
+
_backend = plt.get_backend()
|
|
1301
|
+
except Exception:
|
|
1302
|
+
_backend = "unknown"
|
|
1303
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1304
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1305
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1306
|
+
if _is_noninteractive:
|
|
1307
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
|
|
1308
|
+
print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
|
|
1309
|
+
print(" export MPLBACKEND=MacOSX # built-in macOS backend")
|
|
1310
|
+
print(" export MPLBACKEND=TkAgg # if Tk is available")
|
|
1311
|
+
print(" export MPLBACKEND=QtAgg # if PyQt is installed")
|
|
1312
|
+
print("Or run without --interactive and use --out to save the figure.")
|
|
1313
|
+
else:
|
|
1314
|
+
try:
|
|
1315
|
+
plt.ion()
|
|
1316
|
+
except Exception:
|
|
1317
|
+
pass
|
|
1318
|
+
plt.show(block=False)
|
|
1319
|
+
try:
|
|
1320
|
+
# Always pass file_data so filename is available
|
|
1321
|
+
cpc_interactive_menu(fig, ax, ax2,
|
|
1322
|
+
file_data[0]['sc_charge'],
|
|
1323
|
+
file_data[0]['sc_discharge'],
|
|
1324
|
+
file_data[0]['sc_eff'],
|
|
1325
|
+
file_data=file_data)
|
|
1326
|
+
except Exception as _ie:
|
|
1327
|
+
print(f"CPC interactive menu failed: {_ie}")
|
|
1328
|
+
# Keep window open after menu
|
|
1329
|
+
plt.show()
|
|
1330
|
+
else:
|
|
1331
|
+
if not (args.savefig or args.out):
|
|
1332
|
+
try:
|
|
1333
|
+
_backend = plt.get_backend()
|
|
1334
|
+
except Exception:
|
|
1335
|
+
_backend = "unknown"
|
|
1336
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1337
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1338
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1339
|
+
if not _is_noninteractive:
|
|
1340
|
+
plt.show()
|
|
1341
|
+
else:
|
|
1342
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
|
|
1343
|
+
exit(0)
|
|
1344
|
+
|
|
1345
|
+
# dQ/dV plotting mode for supported .csv electrochemistry exports
|
|
1346
|
+
if getattr(args, 'dqdv', False):
|
|
1347
|
+
import os as _os
|
|
1348
|
+
import matplotlib.pyplot as _plt
|
|
1349
|
+
|
|
1350
|
+
# Separate style files from data files
|
|
1351
|
+
data_files = []
|
|
1352
|
+
style_file_path = None
|
|
1353
|
+
for f in args.files:
|
|
1354
|
+
ext = os.path.splitext(f)[1].lower()
|
|
1355
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
1356
|
+
if style_file_path is None:
|
|
1357
|
+
style_file_path = f
|
|
1358
|
+
else:
|
|
1359
|
+
print(f"Warning: Multiple style files provided, using first: {style_file_path}")
|
|
1360
|
+
else:
|
|
1361
|
+
data_files.append(f)
|
|
1362
|
+
|
|
1363
|
+
if not data_files:
|
|
1364
|
+
print("dQ/dV mode: no data files found (only style files provided).")
|
|
1365
|
+
exit(1)
|
|
1366
|
+
|
|
1367
|
+
# Load style file if provided
|
|
1368
|
+
style_cfg = None
|
|
1369
|
+
if style_file_path:
|
|
1370
|
+
if not os.path.isfile(style_file_path):
|
|
1371
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
1372
|
+
else:
|
|
1373
|
+
try:
|
|
1374
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
1375
|
+
style_cfg = json.load(f)
|
|
1376
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
1377
|
+
except Exception as e:
|
|
1378
|
+
print(f"Warning: Could not load style file {style_file_path}: {e}")
|
|
1379
|
+
|
|
1380
|
+
# Process each data file
|
|
1381
|
+
from .utils import ensure_subdirectory
|
|
1382
|
+
out_dir = None
|
|
1383
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
1384
|
+
# Multiple files: create output directory
|
|
1385
|
+
out_dir = ensure_subdirectory('Figures', os.getcwd())
|
|
1386
|
+
|
|
1387
|
+
for ec_file in data_files:
|
|
1388
|
+
if not _os.path.isfile(ec_file):
|
|
1389
|
+
print(f"File not found: {ec_file}")
|
|
1390
|
+
continue
|
|
1391
|
+
if not (ec_file.lower().endswith('.csv') or ec_file.lower().endswith('.mpt')):
|
|
1392
|
+
print(f"dQ/dV mode: file must be a supported cycler .csv or .mpt export: {ec_file}")
|
|
1393
|
+
continue
|
|
1394
|
+
|
|
1395
|
+
try:
|
|
1396
|
+
# Load voltage, dQ/dV, cycles, and charge/discharge masks
|
|
1397
|
+
if ec_file.lower().endswith('.mpt'):
|
|
1398
|
+
# .mpt files require mass for dQ/dV calculation
|
|
1399
|
+
mass_mg = getattr(args, 'mass', None)
|
|
1400
|
+
if mass_mg is None or mass_mg <= 0:
|
|
1401
|
+
print(f"dQ/dV mode (.mpt): --mass parameter is required (active material mass in milligrams).")
|
|
1402
|
+
print(f"Example: batplot {ec_file} --dqdv --mass 7.0")
|
|
1403
|
+
continue
|
|
1404
|
+
voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_mpt_dqdv_file(ec_file, mass_mg=mass_mg, prefer_specific=True)
|
|
1405
|
+
else:
|
|
1406
|
+
# Check if this is CS-B format
|
|
1407
|
+
try:
|
|
1408
|
+
header, _, _ = _load_csv_header_and_rows(ec_file)
|
|
1409
|
+
if is_cs_b_format(header):
|
|
1410
|
+
# Use CS-B format reader
|
|
1411
|
+
voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_cs_b_csv_file(ec_file, mode='dqdv')
|
|
1412
|
+
else:
|
|
1413
|
+
# Use standard CSV reader
|
|
1414
|
+
voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_ec_csv_dqdv_file(ec_file, prefer_specific=True)
|
|
1415
|
+
except Exception:
|
|
1416
|
+
# Fallback to standard reader
|
|
1417
|
+
voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = read_ec_csv_dqdv_file(ec_file, prefer_specific=True)
|
|
1418
|
+
|
|
1419
|
+
# Create the plot
|
|
1420
|
+
fig, ax = _plt.subplots(figsize=(10, 6))
|
|
1421
|
+
|
|
1422
|
+
def _mask_segments(mask: np.ndarray, role: str):
|
|
1423
|
+
inds = np.where(mask)[0]
|
|
1424
|
+
if inds.size == 0:
|
|
1425
|
+
return []
|
|
1426
|
+
segments = []
|
|
1427
|
+
start = inds[0]
|
|
1428
|
+
prev = inds[0]
|
|
1429
|
+
for idx in inds[1:]:
|
|
1430
|
+
if idx == prev + 1:
|
|
1431
|
+
prev = idx
|
|
1432
|
+
else:
|
|
1433
|
+
segments.append((start, prev, role))
|
|
1434
|
+
start = idx
|
|
1435
|
+
prev = idx
|
|
1436
|
+
segments.append((start, prev, role))
|
|
1437
|
+
return segments
|
|
1438
|
+
|
|
1439
|
+
segments = _mask_segments(charge_mask, 'charge') + _mask_segments(discharge_mask, 'discharge')
|
|
1440
|
+
segments.sort(key=lambda item: item[0])
|
|
1441
|
+
|
|
1442
|
+
base_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
1443
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
1444
|
+
|
|
1445
|
+
cycle_lines = {}
|
|
1446
|
+
ax._is_dqdv_mode = True
|
|
1447
|
+
cycle_id = 1
|
|
1448
|
+
cycle_lines[cycle_id] = {"charge": None, "discharge": None}
|
|
1449
|
+
|
|
1450
|
+
def _append_segment(line_obj, x_new, y_new):
|
|
1451
|
+
try:
|
|
1452
|
+
x_old = np.asarray(line_obj.get_xdata(), float)
|
|
1453
|
+
y_old = np.asarray(line_obj.get_ydata(), float)
|
|
1454
|
+
x_cat = np.concatenate([x_old, np.array([np.nan]), x_new])
|
|
1455
|
+
y_cat = np.concatenate([y_old, np.array([np.nan]), y_new])
|
|
1456
|
+
line_obj.set_xdata(x_cat)
|
|
1457
|
+
line_obj.set_ydata(y_cat)
|
|
1458
|
+
except Exception:
|
|
1459
|
+
pass
|
|
1460
|
+
|
|
1461
|
+
for start, end, role in segments:
|
|
1462
|
+
if end - start + 1 < 2:
|
|
1463
|
+
continue
|
|
1464
|
+
idx = np.arange(start, end + 1)
|
|
1465
|
+
x_seg = voltage[idx]
|
|
1466
|
+
y_seg = dqdv[idx]
|
|
1467
|
+
current = cycle_lines.setdefault(cycle_id, {"charge": None, "discharge": None})
|
|
1468
|
+
color = base_colors[(cycle_id - 1) % len(base_colors)]
|
|
1469
|
+
first_segment = current['charge'] is None and current['discharge'] is None
|
|
1470
|
+
|
|
1471
|
+
if current[role] is not None:
|
|
1472
|
+
if current['charge'] is not None and current['discharge'] is not None:
|
|
1473
|
+
cycle_id += 1
|
|
1474
|
+
current = cycle_lines.setdefault(cycle_id, {"charge": None, "discharge": None})
|
|
1475
|
+
else:
|
|
1476
|
+
# Swap x and y if --ro flag is set when appending segment
|
|
1477
|
+
if getattr(args, 'ro', False):
|
|
1478
|
+
_append_segment(current[role], y_seg, x_seg)
|
|
1479
|
+
else:
|
|
1480
|
+
_append_segment(current[role], x_seg, y_seg)
|
|
1481
|
+
continue
|
|
1482
|
+
|
|
1483
|
+
label = str(cycle_id) if first_segment else '_nolegend_'
|
|
1484
|
+
# Swap x and y if --ro flag is set
|
|
1485
|
+
if getattr(args, 'ro', False):
|
|
1486
|
+
ln, = ax.plot(y_seg, x_seg, '-', color=color, linewidth=2.0, label=label, alpha=0.8)
|
|
1487
|
+
else:
|
|
1488
|
+
ln, = ax.plot(x_seg, y_seg, '-', color=color, linewidth=2.0, label=label, alpha=0.8)
|
|
1489
|
+
current[role] = ln
|
|
1490
|
+
|
|
1491
|
+
if current['charge'] is not None and current['discharge'] is not None:
|
|
1492
|
+
cycle_id += 1
|
|
1493
|
+
|
|
1494
|
+
if cycle_lines.get(cycle_id) == {"charge": None, "discharge": None}:
|
|
1495
|
+
cycle_lines.pop(cycle_id, None)
|
|
1496
|
+
|
|
1497
|
+
# Labels with consistent labelpad (same as GC/CPC)
|
|
1498
|
+
# Swap axis labels if --ro flag is set
|
|
1499
|
+
if getattr(args, 'ro', False):
|
|
1500
|
+
ax.set_xlabel(y_label, labelpad=8.0)
|
|
1501
|
+
ax.set_ylabel('Voltage (V)', labelpad=8.0)
|
|
1502
|
+
else:
|
|
1503
|
+
|
|
1504
|
+
ax.set_xlabel('Voltage (V)', labelpad=8.0)
|
|
1505
|
+
ax.set_ylabel(y_label, labelpad=8.0)
|
|
1506
|
+
legend = ax.legend(title='Cycle')
|
|
1507
|
+
if legend is not None:
|
|
1508
|
+
try:
|
|
1509
|
+
legend.set_frame_on(False)
|
|
1510
|
+
except Exception:
|
|
1511
|
+
pass
|
|
1512
|
+
legend.get_title().set_fontsize('medium')
|
|
1513
|
+
# No background grid by default (same as GC)
|
|
1514
|
+
|
|
1515
|
+
# Adjust layout to ensure top and bottom labels/titles are visible (same as GC/CPC)
|
|
1516
|
+
fig.subplots_adjust(left=0.12, right=0.95, top=0.88, bottom=0.15)
|
|
1517
|
+
|
|
1518
|
+
# Apply style file if provided
|
|
1519
|
+
if style_cfg:
|
|
1520
|
+
try:
|
|
1521
|
+
from .batch import _apply_ec_style
|
|
1522
|
+
_apply_ec_style(fig, ax, style_cfg)
|
|
1523
|
+
# Redraw after applying style
|
|
1524
|
+
if hasattr(fig, 'canvas'):
|
|
1525
|
+
fig.canvas.draw()
|
|
1526
|
+
except Exception as e:
|
|
1527
|
+
print(f"Warning: Error applying style file: {e}")
|
|
1528
|
+
|
|
1529
|
+
# Save if requested
|
|
1530
|
+
if len(data_files) > 1 and (args.savefig or args.out):
|
|
1531
|
+
# Multiple files: save to Figures/ directory
|
|
1532
|
+
base_name = os.path.splitext(os.path.basename(ec_file))[0]
|
|
1533
|
+
output_format = getattr(args, 'format', 'svg')
|
|
1534
|
+
outname = os.path.join(out_dir, f"{base_name}.{output_format}")
|
|
1535
|
+
else:
|
|
1536
|
+
outname = args.savefig or args.out
|
|
1537
|
+
if outname:
|
|
1538
|
+
if not _os.path.splitext(outname)[1]:
|
|
1539
|
+
outname += '.svg'
|
|
1540
|
+
_, _ext = _os.path.splitext(outname)
|
|
1541
|
+
if _ext.lower() == '.svg':
|
|
1542
|
+
try:
|
|
1543
|
+
_fig_fc = fig.get_facecolor()
|
|
1544
|
+
except Exception:
|
|
1545
|
+
_fig_fc = None
|
|
1546
|
+
try:
|
|
1547
|
+
_ax_fc = ax.get_facecolor()
|
|
1548
|
+
except Exception:
|
|
1549
|
+
_ax_fc = None
|
|
1550
|
+
try:
|
|
1551
|
+
if getattr(fig, 'patch', None) is not None:
|
|
1552
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
1553
|
+
if getattr(ax, 'patch', None) is not None:
|
|
1554
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
1555
|
+
except Exception:
|
|
1556
|
+
pass
|
|
1557
|
+
try:
|
|
1558
|
+
fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
1559
|
+
finally:
|
|
1560
|
+
try:
|
|
1561
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
1562
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
1563
|
+
except Exception:
|
|
1564
|
+
pass
|
|
1565
|
+
try:
|
|
1566
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
1567
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
1568
|
+
except Exception:
|
|
1569
|
+
pass
|
|
1570
|
+
else:
|
|
1571
|
+
fig.savefig(outname, dpi=300)
|
|
1572
|
+
print(f"dQ/dV plot saved to {outname} ({y_label})")
|
|
1573
|
+
|
|
1574
|
+
# Show / interactive
|
|
1575
|
+
if args.interactive:
|
|
1576
|
+
try:
|
|
1577
|
+
_backend = _plt.get_backend()
|
|
1578
|
+
except Exception:
|
|
1579
|
+
_backend = "unknown"
|
|
1580
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1581
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1582
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1583
|
+
if _is_noninteractive:
|
|
1584
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
|
|
1585
|
+
print("Tips: unset MPLBACKEND or set a GUI backend, e.g. on macOS:")
|
|
1586
|
+
print(" export MPLBACKEND=MacOSX # built-in macOS backend")
|
|
1587
|
+
print(" export MPLBACKEND=TkAgg # if Tk is available")
|
|
1588
|
+
print(" export MPLBACKEND=QtAgg # if PyQt is installed")
|
|
1589
|
+
print("Or run without --interactive and use --out to save the figure.")
|
|
1590
|
+
else:
|
|
1591
|
+
try:
|
|
1592
|
+
_plt.ion()
|
|
1593
|
+
except Exception:
|
|
1594
|
+
pass
|
|
1595
|
+
_plt.show(block=False)
|
|
1596
|
+
try:
|
|
1597
|
+
fig._bp_source_paths = [_os.path.abspath(ec_file)]
|
|
1598
|
+
except Exception:
|
|
1599
|
+
pass
|
|
1600
|
+
try:
|
|
1601
|
+
electrochem_interactive_menu(fig, ax, cycle_lines, file_path=ec_file)
|
|
1602
|
+
except Exception as _ie:
|
|
1603
|
+
print(f"Interactive menu failed: {_ie}")
|
|
1604
|
+
_plt.show()
|
|
1605
|
+
else:
|
|
1606
|
+
if not (args.savefig or args.out):
|
|
1607
|
+
try:
|
|
1608
|
+
_backend = _plt.get_backend()
|
|
1609
|
+
except Exception:
|
|
1610
|
+
_backend = "unknown"
|
|
1611
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1612
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1613
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1614
|
+
if not _is_noninteractive:
|
|
1615
|
+
_plt.show()
|
|
1616
|
+
else:
|
|
1617
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
|
|
1618
|
+
# For multiple files, close the figure and continue to next file
|
|
1619
|
+
if len(data_files) > 1:
|
|
1620
|
+
_plt.close(fig)
|
|
1621
|
+
continue
|
|
1622
|
+
else:
|
|
1623
|
+
exit()
|
|
1624
|
+
except Exception as _e:
|
|
1625
|
+
print(f"dQ/dV plot failed for {ec_file}: {_e}")
|
|
1626
|
+
if len(data_files) > 1:
|
|
1627
|
+
continue
|
|
1628
|
+
else:
|
|
1629
|
+
exit(1)
|
|
1630
|
+
# Exit after processing all files
|
|
1631
|
+
if len(data_files) > 1:
|
|
1632
|
+
print(f"Processed {len(data_files)} dQ/dV files.")
|
|
1633
|
+
exit(0)
|
|
1634
|
+
|
|
1635
|
+
# Operando contour plotting mode (folder-based)
|
|
1636
|
+
if getattr(args, 'operando', False):
|
|
1637
|
+
import os as _os
|
|
1638
|
+
import matplotlib.pyplot as _plt
|
|
1639
|
+
try:
|
|
1640
|
+
# Determine target folder: explicit folder arg or current directory
|
|
1641
|
+
if len(args.files) == 0:
|
|
1642
|
+
folder = os.getcwd()
|
|
1643
|
+
elif len(args.files) == 1 and _os.path.isdir(args.files[0]):
|
|
1644
|
+
folder = _os.path.abspath(args.files[0])
|
|
1645
|
+
elif len(args.files) == 1 and not _os.path.isdir(args.files[0]):
|
|
1646
|
+
print("Operando mode expects a folder (or no argument to use current folder).")
|
|
1647
|
+
exit(1)
|
|
1648
|
+
else:
|
|
1649
|
+
print("Operando mode: provide at most one folder or no argument.")
|
|
1650
|
+
exit(1)
|
|
1651
|
+
|
|
1652
|
+
# Build plot
|
|
1653
|
+
fig, ax, meta = plot_operando_folder(folder, args)
|
|
1654
|
+
im = meta.get('imshow')
|
|
1655
|
+
cbar = meta.get('colorbar')
|
|
1656
|
+
has_ec = bool(meta.get('has_ec'))
|
|
1657
|
+
ec_ax = meta.get('ec_ax') if has_ec else None
|
|
1658
|
+
|
|
1659
|
+
# Save if requested
|
|
1660
|
+
outname = args.savefig or args.out
|
|
1661
|
+
if outname:
|
|
1662
|
+
if not _os.path.splitext(outname)[1]:
|
|
1663
|
+
outname += '.svg'
|
|
1664
|
+
_, _ext = _os.path.splitext(outname)
|
|
1665
|
+
if _ext.lower() == '.svg':
|
|
1666
|
+
try:
|
|
1667
|
+
_fig_fc = fig.get_facecolor()
|
|
1668
|
+
except Exception:
|
|
1669
|
+
_fig_fc = None
|
|
1670
|
+
try:
|
|
1671
|
+
_ax_fc = ax.get_facecolor()
|
|
1672
|
+
except Exception:
|
|
1673
|
+
_ax_fc = None
|
|
1674
|
+
try:
|
|
1675
|
+
if getattr(fig, 'patch', None) is not None:
|
|
1676
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
1677
|
+
if getattr(ax, 'patch', None) is not None:
|
|
1678
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
1679
|
+
if ec_ax is not None and getattr(ec_ax, 'patch', None) is not None:
|
|
1680
|
+
ec_ax.patch.set_alpha(0.0); ec_ax.patch.set_facecolor('none')
|
|
1681
|
+
except Exception:
|
|
1682
|
+
pass
|
|
1683
|
+
try:
|
|
1684
|
+
fig.savefig(outname, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
1685
|
+
finally:
|
|
1686
|
+
try:
|
|
1687
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
1688
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
1689
|
+
except Exception:
|
|
1690
|
+
pass
|
|
1691
|
+
try:
|
|
1692
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
1693
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
1694
|
+
except Exception:
|
|
1695
|
+
pass
|
|
1696
|
+
else:
|
|
1697
|
+
fig.savefig(outname, dpi=300)
|
|
1698
|
+
print(f"Operando plot saved to {outname}")
|
|
1699
|
+
|
|
1700
|
+
# Interactive or show
|
|
1701
|
+
if args.interactive:
|
|
1702
|
+
try:
|
|
1703
|
+
_backend = _plt.get_backend()
|
|
1704
|
+
except Exception:
|
|
1705
|
+
_backend = "unknown"
|
|
1706
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1707
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1708
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1709
|
+
if _is_noninteractive:
|
|
1710
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; a window cannot be shown.")
|
|
1711
|
+
print("Tips: unset MPLBACKEND or set a GUI backend")
|
|
1712
|
+
print("Or run without --interactive and use --out to save the figure.")
|
|
1713
|
+
else:
|
|
1714
|
+
try:
|
|
1715
|
+
_plt.ion()
|
|
1716
|
+
except Exception:
|
|
1717
|
+
pass
|
|
1718
|
+
try:
|
|
1719
|
+
_plt.show(block=False)
|
|
1720
|
+
except Exception:
|
|
1721
|
+
pass
|
|
1722
|
+
try:
|
|
1723
|
+
# Call interactive menu regardless of EC presence
|
|
1724
|
+
# When ec_ax is None, EC-related commands will be disabled
|
|
1725
|
+
if operando_ec_interactive_menu is not None:
|
|
1726
|
+
operando_ec_interactive_menu(fig, ax, im, cbar, ec_ax, file_paths=args.files)
|
|
1727
|
+
else:
|
|
1728
|
+
print("Interactive menu not available.")
|
|
1729
|
+
except Exception as _ie:
|
|
1730
|
+
print(f"Interactive menu failed: {_ie}")
|
|
1731
|
+
_plt.show()
|
|
1732
|
+
else:
|
|
1733
|
+
if not (args.savefig or args.out):
|
|
1734
|
+
try:
|
|
1735
|
+
_backend = _plt.get_backend()
|
|
1736
|
+
except Exception:
|
|
1737
|
+
_backend = "unknown"
|
|
1738
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
1739
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
1740
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
1741
|
+
if not _is_noninteractive:
|
|
1742
|
+
_plt.show()
|
|
1743
|
+
else:
|
|
1744
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
|
|
1745
|
+
exit()
|
|
1746
|
+
except Exception as _e:
|
|
1747
|
+
print(f"Operando plot failed: {_e}")
|
|
1748
|
+
exit(1)
|
|
1749
|
+
|
|
1750
|
+
_maybe_expand_allfiles_argument(args, ec_mode_active)
|
|
1751
|
+
|
|
1752
|
+
if len(args.files) == 1:
|
|
1753
|
+
sole = args.files[0]
|
|
1754
|
+
if sole.lower() == 'all':
|
|
1755
|
+
batch_process(os.getcwd(), args)
|
|
1756
|
+
exit()
|
|
1757
|
+
elif sole.lower() == 'allfiles':
|
|
1758
|
+
_prepare_allfiles_directory(os.getcwd(), args, use_relative_paths=True)
|
|
1759
|
+
# Continue to normal plotting mode with all files
|
|
1760
|
+
elif os.path.isdir(sole):
|
|
1761
|
+
batch_process(os.path.abspath(sole), args)
|
|
1762
|
+
exit()
|
|
1763
|
+
|
|
1764
|
+
# --- XY Batch Mode: check for --all flag for XY files ---
|
|
1765
|
+
# Handle --all flag for XY batch processing (consistent with EC batch mode)
|
|
1766
|
+
if not ec_mode_active and getattr(args, 'all', None) is not None:
|
|
1767
|
+
batch_process(os.getcwd(), args)
|
|
1768
|
+
exit()
|
|
1769
|
+
|
|
1770
|
+
# ---------------- Normal (multi-file) path continues below ----------------
|
|
1771
|
+
# Apply conditional default for delta (normal mode only)
|
|
1772
|
+
if args.delta is None:
|
|
1773
|
+
args.delta = 0.1 if args.stack else 0.0
|
|
1774
|
+
|
|
1775
|
+
# ---------------- Automatic session (.pkl) load shortcut ----------------
|
|
1776
|
+
# If user invokes: batplot session.pkl [--interactive]
|
|
1777
|
+
if len(args.files) == 1 and args.files[0].lower().endswith('.pkl'):
|
|
1778
|
+
sess_path = args.files[0]
|
|
1779
|
+
if not os.path.isfile(sess_path):
|
|
1780
|
+
print(f"Session file not found: {sess_path}")
|
|
1781
|
+
exit(1)
|
|
1782
|
+
try:
|
|
1783
|
+
with open(sess_path, 'rb') as f:
|
|
1784
|
+
sess = pickle.load(f)
|
|
1785
|
+
if not isinstance(sess, dict) or 'version' not in sess:
|
|
1786
|
+
print("Not a valid batplot session file.")
|
|
1787
|
+
exit(1)
|
|
1788
|
+
except Exception as e:
|
|
1789
|
+
print(f"Failed to load session: {e}")
|
|
1790
|
+
exit(1)
|
|
1791
|
+
# If it's an EC GC session, load and open EC interactive menu directly
|
|
1792
|
+
if isinstance(sess, dict) and sess.get('kind') == 'ec_gc':
|
|
1793
|
+
try:
|
|
1794
|
+
import matplotlib.pyplot as _plt
|
|
1795
|
+
res = load_ec_session(sess_path)
|
|
1796
|
+
if not res:
|
|
1797
|
+
print("Failed to load EC session.")
|
|
1798
|
+
exit(1)
|
|
1799
|
+
fig, ax, cycle_lines = res
|
|
1800
|
+
try:
|
|
1801
|
+
_plt.ion()
|
|
1802
|
+
except Exception:
|
|
1803
|
+
pass
|
|
1804
|
+
try:
|
|
1805
|
+
_plt.show(block=False)
|
|
1806
|
+
except Exception:
|
|
1807
|
+
pass
|
|
1808
|
+
try:
|
|
1809
|
+
source_list = list(getattr(fig, '_bp_source_paths', []) or [])
|
|
1810
|
+
sess_abs = os.path.abspath(sess_path)
|
|
1811
|
+
if sess_abs not in source_list:
|
|
1812
|
+
source_list.append(sess_abs)
|
|
1813
|
+
fig._bp_source_paths = source_list
|
|
1814
|
+
except Exception:
|
|
1815
|
+
pass
|
|
1816
|
+
try:
|
|
1817
|
+
electrochem_interactive_menu(fig, ax, cycle_lines, file_path=sess_path)
|
|
1818
|
+
except Exception as _ie:
|
|
1819
|
+
print(f"Interactive menu failed: {_ie}")
|
|
1820
|
+
_plt.show()
|
|
1821
|
+
exit()
|
|
1822
|
+
except Exception as e:
|
|
1823
|
+
print(f"EC session load failed: {e}")
|
|
1824
|
+
exit(1)
|
|
1825
|
+
# If it's an operando+EC session, load and open the combined interactive menu
|
|
1826
|
+
if isinstance(sess, dict) and sess.get('kind') == 'operando_ec':
|
|
1827
|
+
try:
|
|
1828
|
+
import matplotlib.pyplot as _plt
|
|
1829
|
+
res = load_operando_session(sess_path)
|
|
1830
|
+
if not res:
|
|
1831
|
+
print("Failed to load operando+EC session.")
|
|
1832
|
+
exit(1)
|
|
1833
|
+
fig2, ax2, im2, cbar2, ec_ax2 = res
|
|
1834
|
+
# Always open interactive menu for session files
|
|
1835
|
+
try:
|
|
1836
|
+
_plt.ion()
|
|
1837
|
+
except Exception:
|
|
1838
|
+
pass
|
|
1839
|
+
try:
|
|
1840
|
+
_plt.show(block=False)
|
|
1841
|
+
except Exception:
|
|
1842
|
+
pass
|
|
1843
|
+
try:
|
|
1844
|
+
if operando_ec_interactive_menu is not None:
|
|
1845
|
+
operando_ec_interactive_menu(fig2, ax2, im2, cbar2, ec_ax2)
|
|
1846
|
+
except Exception as _ie:
|
|
1847
|
+
print(f"Interactive menu failed: {_ie}")
|
|
1848
|
+
_plt.show()
|
|
1849
|
+
exit()
|
|
1850
|
+
except Exception as e:
|
|
1851
|
+
print(f"Operando+EC session load failed: {e}")
|
|
1852
|
+
exit(1)
|
|
1853
|
+
|
|
1854
|
+
# If it's a CPC session, load and open CPC interactive menu
|
|
1855
|
+
if isinstance(sess, dict) and sess.get('kind') == 'cpc':
|
|
1856
|
+
try:
|
|
1857
|
+
import matplotlib.pyplot as _plt
|
|
1858
|
+
res = load_cpc_session(sess_path)
|
|
1859
|
+
if not res:
|
|
1860
|
+
print("Failed to load CPC session.")
|
|
1861
|
+
exit(1)
|
|
1862
|
+
fig_c, ax_c, ax2_c, sc_c, sc_d, sc_e, file_data = res
|
|
1863
|
+
try:
|
|
1864
|
+
_plt.ion()
|
|
1865
|
+
except Exception:
|
|
1866
|
+
pass
|
|
1867
|
+
try:
|
|
1868
|
+
_plt.show(block=False)
|
|
1869
|
+
except Exception:
|
|
1870
|
+
pass
|
|
1871
|
+
try:
|
|
1872
|
+
if cpc_interactive_menu is not None:
|
|
1873
|
+
cpc_interactive_menu(fig_c, ax_c, ax2_c, sc_c, sc_d, sc_e, file_data=file_data)
|
|
1874
|
+
except Exception as _ie:
|
|
1875
|
+
print(f"CPC interactive menu failed: {_ie}")
|
|
1876
|
+
_plt.show()
|
|
1877
|
+
exit()
|
|
1878
|
+
except Exception as e:
|
|
1879
|
+
print(f"CPC session load failed: {e}")
|
|
1880
|
+
exit(1)
|
|
1881
|
+
|
|
1882
|
+
# Reconstruct minimal state and go to interactive if requested
|
|
1883
|
+
plt.ion() if args.interactive else None
|
|
1884
|
+
fig, ax = plt.subplots(figsize=(8,6))
|
|
1885
|
+
y_data_list = []
|
|
1886
|
+
x_data_list = []
|
|
1887
|
+
labels_list = []
|
|
1888
|
+
orig_y = []
|
|
1889
|
+
label_text_objects = []
|
|
1890
|
+
x_full_list = []
|
|
1891
|
+
raw_y_full_list = []
|
|
1892
|
+
offsets_list = []
|
|
1893
|
+
tick_state = {
|
|
1894
|
+
'bx': True,'tx': False,'ly': True,'ry': False,
|
|
1895
|
+
'mbx': False,'mtx': False,'mly': False,'mry': False
|
|
1896
|
+
}
|
|
1897
|
+
saved_stack = bool(sess.get('args_subset', {}).get('stack', False))
|
|
1898
|
+
# Pull data
|
|
1899
|
+
# --- Robust reconstruction of stored curves ---
|
|
1900
|
+
x_loaded = sess.get('x_data', [])
|
|
1901
|
+
y_loaded = sess.get('y_data', []) # stored plotted (baseline+offset) values
|
|
1902
|
+
orig_loaded = sess.get('orig_y', []) # stored baseline (normalized/raw w/out offsets)
|
|
1903
|
+
offsets_saved = sess.get('offsets', [])
|
|
1904
|
+
n_curves = len(x_loaded)
|
|
1905
|
+
for i in range(n_curves):
|
|
1906
|
+
x_arr = np.array(x_loaded[i])
|
|
1907
|
+
off = offsets_saved[i] if i < len(offsets_saved) else 0.0
|
|
1908
|
+
if orig_loaded and i < len(orig_loaded):
|
|
1909
|
+
base = np.array(orig_loaded[i])
|
|
1910
|
+
else:
|
|
1911
|
+
# Fallback: derive baseline by subtracting offset from stored y (handles legacy sessions)
|
|
1912
|
+
y_arr_full = np.array(y_loaded[i]) if i < len(y_loaded) else np.array([])
|
|
1913
|
+
base = y_arr_full - off
|
|
1914
|
+
y_plot = base + off
|
|
1915
|
+
x_data_list.append(x_arr)
|
|
1916
|
+
orig_y.append(base)
|
|
1917
|
+
y_data_list.append(y_plot)
|
|
1918
|
+
ax.plot(x_arr, y_plot, lw=1)
|
|
1919
|
+
x_full_list.append(x_arr.copy())
|
|
1920
|
+
raw_y_full_list.append(base.copy())
|
|
1921
|
+
offsets_list[:] = offsets_saved if offsets_saved else [0.0]*n_curves
|
|
1922
|
+
try:
|
|
1923
|
+
axes_bbox = sess.get('figure', {}).get('axes_bbox')
|
|
1924
|
+
if _session_apply_axes_bbox(ax, axes_bbox):
|
|
1925
|
+
try:
|
|
1926
|
+
fig._skip_initial_text_visibility = True
|
|
1927
|
+
except Exception:
|
|
1928
|
+
pass
|
|
1929
|
+
except Exception:
|
|
1930
|
+
pass
|
|
1931
|
+
# Apply stored line styles (if any)
|
|
1932
|
+
try:
|
|
1933
|
+
stored_styles = sess.get('line_styles', [])
|
|
1934
|
+
for ln, st in zip(ax.lines, stored_styles):
|
|
1935
|
+
if 'color' in st: ln.set_color(st['color'])
|
|
1936
|
+
if 'linewidth' in st: ln.set_linewidth(st['linewidth'])
|
|
1937
|
+
if 'linestyle' in st:
|
|
1938
|
+
try: ln.set_linestyle(st['linestyle'])
|
|
1939
|
+
except Exception: pass
|
|
1940
|
+
if 'alpha' in st and st['alpha'] is not None: ln.set_alpha(st['alpha'])
|
|
1941
|
+
if 'marker' in st and st['marker'] is not None:
|
|
1942
|
+
try: ln.set_marker(st['marker'])
|
|
1943
|
+
except Exception: pass
|
|
1944
|
+
if 'markersize' in st and st['markersize'] is not None:
|
|
1945
|
+
try: ln.set_markersize(st['markersize'])
|
|
1946
|
+
except Exception: pass
|
|
1947
|
+
if 'markerfacecolor' in st and st['markerfacecolor'] is not None:
|
|
1948
|
+
try: ln.set_markerfacecolor(st['markerfacecolor'])
|
|
1949
|
+
except Exception: pass
|
|
1950
|
+
if 'markeredgecolor' in st and st['markeredgecolor'] is not None:
|
|
1951
|
+
try: ln.set_markeredgecolor(st['markeredgecolor'])
|
|
1952
|
+
except Exception: pass
|
|
1953
|
+
except Exception:
|
|
1954
|
+
pass
|
|
1955
|
+
labels_list[:] = sess.get('labels', [f"Curve {i+1}" for i in range(len(y_data_list))])
|
|
1956
|
+
delta = sess.get('delta', 0.0)
|
|
1957
|
+
ax.set_xlabel(sess.get('axis', {}).get('xlabel', 'X'))
|
|
1958
|
+
ax.set_ylabel(sess.get('axis', {}).get('ylabel', 'Intensity'))
|
|
1959
|
+
|
|
1960
|
+
# Restore normalization ranges (if saved)
|
|
1961
|
+
axis_cfg = sess.get('axis', {})
|
|
1962
|
+
if 'norm_xlim' in axis_cfg and axis_cfg['norm_xlim'] is not None:
|
|
1963
|
+
ax._norm_xlim = tuple(axis_cfg['norm_xlim'])
|
|
1964
|
+
if 'norm_ylim' in axis_cfg and axis_cfg['norm_ylim'] is not None:
|
|
1965
|
+
ax._norm_ylim = tuple(axis_cfg['norm_ylim'])
|
|
1966
|
+
|
|
1967
|
+
# Restore display limits
|
|
1968
|
+
if 'xlim' in axis_cfg:
|
|
1969
|
+
ax.set_xlim(*axis_cfg['xlim'])
|
|
1970
|
+
if 'ylim' in axis_cfg:
|
|
1971
|
+
ax.set_ylim(*axis_cfg['ylim'])
|
|
1972
|
+
# Apply figure size & dpi if stored
|
|
1973
|
+
fig_cfg = sess.get('figure', {})
|
|
1974
|
+
try:
|
|
1975
|
+
if fig_cfg.get('size') and isinstance(fig_cfg['size'], (list, tuple)) and len(fig_cfg['size']) == 2:
|
|
1976
|
+
fw, fh = fig_cfg['size']
|
|
1977
|
+
if not globals().get('keep_canvas_fixed', True):
|
|
1978
|
+
fig.set_size_inches(float(fw), float(fh), forward=True)
|
|
1979
|
+
else:
|
|
1980
|
+
# Keep canvas size as current; avoid surprising resize on load
|
|
1981
|
+
pass
|
|
1982
|
+
# Don't restore saved DPI - use system default to avoid display-dependent issues
|
|
1983
|
+
# (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
|
|
1984
|
+
# Keeping figure size in inches ensures consistent appearance across platforms
|
|
1985
|
+
except Exception:
|
|
1986
|
+
pass
|
|
1987
|
+
# Restore spines (linewidth, color, visibility) and subplot margins/tick widths (for CLI .pkl load)
|
|
1988
|
+
try:
|
|
1989
|
+
spine_specs = fig_cfg.get('spines', {})
|
|
1990
|
+
if spine_specs:
|
|
1991
|
+
for name, spec in spine_specs.items():
|
|
1992
|
+
spn = ax.spines.get(name)
|
|
1993
|
+
if not spn: continue
|
|
1994
|
+
if 'linewidth' in spec: spn.set_linewidth(spec['linewidth'])
|
|
1995
|
+
if 'color' in spec and spec['color'] is not None: spn.set_edgecolor(spec['color'])
|
|
1996
|
+
if 'visible' in spec: spn.set_visible(bool(spec['visible']))
|
|
1997
|
+
else:
|
|
1998
|
+
# legacy fallback
|
|
1999
|
+
legacy_vis = fig_cfg.get('spine_vis', {})
|
|
2000
|
+
for name, vis in legacy_vis.items():
|
|
2001
|
+
spn = ax.spines.get(name)
|
|
2002
|
+
if spn:
|
|
2003
|
+
spn.set_visible(bool(vis))
|
|
2004
|
+
spm = fig_cfg.get('subplot_margins')
|
|
2005
|
+
if spm and all(k in spm for k in ('left','right','bottom','top')):
|
|
2006
|
+
fig.subplots_adjust(left=spm['left'], right=spm['right'], bottom=spm['bottom'], top=spm['top'])
|
|
2007
|
+
try:
|
|
2008
|
+
fig._skip_initial_text_visibility = True
|
|
2009
|
+
except Exception:
|
|
2010
|
+
pass
|
|
2011
|
+
|
|
2012
|
+
# Restore exact frame size if stored (for precision)
|
|
2013
|
+
frame_size = fig_cfg.get('frame_size')
|
|
2014
|
+
if frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
|
|
2015
|
+
target_w_in, target_h_in = map(float, frame_size)
|
|
2016
|
+
# Get current canvas size
|
|
2017
|
+
canvas_w_in, canvas_h_in = fig.get_size_inches()
|
|
2018
|
+
# Calculate needed fractions to achieve exact frame size
|
|
2019
|
+
if canvas_w_in > 0 and canvas_h_in > 0:
|
|
2020
|
+
# Get current position to preserve centering
|
|
2021
|
+
bbox = ax.get_position()
|
|
2022
|
+
center_x = (bbox.x0 + bbox.x1) / 2.0
|
|
2023
|
+
center_y = (bbox.y0 + bbox.y1) / 2.0
|
|
2024
|
+
# Calculate new fractions
|
|
2025
|
+
new_w_frac = target_w_in / canvas_w_in
|
|
2026
|
+
new_h_frac = target_h_in / canvas_h_in
|
|
2027
|
+
# Reposition to maintain centering
|
|
2028
|
+
new_left = center_x - new_w_frac / 2.0
|
|
2029
|
+
new_right = center_x + new_w_frac / 2.0
|
|
2030
|
+
new_bottom = center_y - new_h_frac / 2.0
|
|
2031
|
+
new_top = center_y + new_h_frac / 2.0
|
|
2032
|
+
# Apply
|
|
2033
|
+
fig.subplots_adjust(left=new_left, right=new_right, bottom=new_bottom, top=new_top)
|
|
2034
|
+
try:
|
|
2035
|
+
fig._skip_initial_text_visibility = True
|
|
2036
|
+
except Exception:
|
|
2037
|
+
pass
|
|
2038
|
+
except Exception:
|
|
2039
|
+
pass
|
|
2040
|
+
# Font
|
|
2041
|
+
font_cfg = sess.get('font', {})
|
|
2042
|
+
if font_cfg.get('chain'):
|
|
2043
|
+
plt.rcParams['font.family'] = 'sans-serif'
|
|
2044
|
+
plt.rcParams['font.sans-serif'] = font_cfg['chain']
|
|
2045
|
+
if font_cfg.get('size'):
|
|
2046
|
+
plt.rcParams['font.size'] = font_cfg['size']
|
|
2047
|
+
# Tick state restore
|
|
2048
|
+
saved_tick = sess.get('tick_state', {})
|
|
2049
|
+
for k,v in saved_tick.items():
|
|
2050
|
+
if k in tick_state: tick_state[k] = v
|
|
2051
|
+
# Persist on axes for interactive menu initialization
|
|
2052
|
+
try:
|
|
2053
|
+
ax._saved_tick_state = dict(tick_state)
|
|
2054
|
+
except Exception:
|
|
2055
|
+
pass
|
|
2056
|
+
# Tick widths restore
|
|
2057
|
+
try:
|
|
2058
|
+
tw = sess.get('tick_widths', {})
|
|
2059
|
+
if tw.get('x_major') is not None:
|
|
2060
|
+
ax.tick_params(axis='x', which='major', width=float(tw['x_major']))
|
|
2061
|
+
if tw.get('x_minor') is not None:
|
|
2062
|
+
ax.tick_params(axis='x', which='minor', width=float(tw['x_minor']))
|
|
2063
|
+
if tw.get('y_major') is not None:
|
|
2064
|
+
ax.tick_params(axis='y', which='major', width=float(tw['y_major']))
|
|
2065
|
+
if tw.get('y_minor') is not None:
|
|
2066
|
+
ax.tick_params(axis='y', which='minor', width=float(tw['y_minor']))
|
|
2067
|
+
except Exception:
|
|
2068
|
+
pass
|
|
2069
|
+
# Tick lengths restore
|
|
2070
|
+
try:
|
|
2071
|
+
tl = sess.get('tick_lengths', {})
|
|
2072
|
+
if tl.get('x_major') is not None or tl.get('y_major') is not None:
|
|
2073
|
+
major_len = tl.get('x_major') or tl.get('y_major')
|
|
2074
|
+
ax.tick_params(axis='both', which='major', length=major_len)
|
|
2075
|
+
if not hasattr(fig, '_tick_lengths'):
|
|
2076
|
+
fig._tick_lengths = {}
|
|
2077
|
+
fig._tick_lengths['major'] = major_len
|
|
2078
|
+
if tl.get('x_minor') is not None or tl.get('y_minor') is not None:
|
|
2079
|
+
minor_len = tl.get('x_minor') or tl.get('y_minor')
|
|
2080
|
+
ax.tick_params(axis='both', which='minor', length=minor_len)
|
|
2081
|
+
if not hasattr(fig, '_tick_lengths'):
|
|
2082
|
+
fig._tick_lengths = {}
|
|
2083
|
+
fig._tick_lengths['minor'] = minor_len
|
|
2084
|
+
except Exception:
|
|
2085
|
+
pass
|
|
2086
|
+
|
|
2087
|
+
# Restore WASD state (spine, ticks, labels, title visibility for all 4 sides)
|
|
2088
|
+
try:
|
|
2089
|
+
wasd = sess.get('wasd_state', {})
|
|
2090
|
+
if wasd:
|
|
2091
|
+
# Store the xlabel/ylabel before applying WASD (to restore hidden titles later if needed)
|
|
2092
|
+
stored_xlabel = ax.get_xlabel()
|
|
2093
|
+
stored_ylabel = ax.get_ylabel()
|
|
2094
|
+
|
|
2095
|
+
# Apply spine visibility
|
|
2096
|
+
for side in ('top', 'bottom', 'left', 'right'):
|
|
2097
|
+
state = wasd.get(side, {})
|
|
2098
|
+
sp = ax.spines.get(side)
|
|
2099
|
+
if sp and 'spine' in state:
|
|
2100
|
+
sp.set_visible(bool(state['spine']))
|
|
2101
|
+
|
|
2102
|
+
# Apply tick and label visibility
|
|
2103
|
+
for side in ('top', 'bottom', 'left', 'right'):
|
|
2104
|
+
state = wasd.get(side, {})
|
|
2105
|
+
if side in ('top', 'bottom'):
|
|
2106
|
+
# X-axis ticks
|
|
2107
|
+
tick_key = 'tick1On' if side == 'top' else 'tick2On'
|
|
2108
|
+
label_key = 'label1On' if side == 'top' else 'label2On'
|
|
2109
|
+
if 'ticks' in state:
|
|
2110
|
+
ax.tick_params(axis='x', which='major', **{tick_key: bool(state['ticks'])})
|
|
2111
|
+
if 'labels' in state:
|
|
2112
|
+
ax.tick_params(axis='x', which='major', **{label_key: bool(state['labels'])})
|
|
2113
|
+
if 'minor' in state:
|
|
2114
|
+
ax.tick_params(axis='x', which='minor', **{tick_key: bool(state['minor'])})
|
|
2115
|
+
else:
|
|
2116
|
+
# Y-axis ticks
|
|
2117
|
+
tick_key = 'tick1On' if side == 'left' else 'tick2On'
|
|
2118
|
+
label_key = 'label1On' if side == 'left' else 'label2On'
|
|
2119
|
+
if 'ticks' in state:
|
|
2120
|
+
ax.tick_params(axis='y', which='major', **{tick_key: bool(state['ticks'])})
|
|
2121
|
+
if 'labels' in state:
|
|
2122
|
+
ax.tick_params(axis='y', which='major', **{label_key: bool(state['labels'])})
|
|
2123
|
+
if 'minor' in state:
|
|
2124
|
+
ax.tick_params(axis='y', which='minor', **{tick_key: bool(state['minor'])})
|
|
2125
|
+
|
|
2126
|
+
# Apply title visibility - CRITICAL: Check title state before restoring labels
|
|
2127
|
+
# Bottom xlabel
|
|
2128
|
+
bottom_title_on = wasd.get('bottom', {}).get('title', True)
|
|
2129
|
+
if bottom_title_on:
|
|
2130
|
+
ax.set_xlabel(stored_xlabel)
|
|
2131
|
+
else:
|
|
2132
|
+
|
|
2133
|
+
ax.set_xlabel('') # Hidden by user via s5
|
|
2134
|
+
# Store the hidden label for later restoration
|
|
2135
|
+
if stored_xlabel:
|
|
2136
|
+
setattr(ax, '_stored_xlabel', stored_xlabel)
|
|
2137
|
+
|
|
2138
|
+
# Left ylabel
|
|
2139
|
+
left_title_on = wasd.get('left', {}).get('title', True)
|
|
2140
|
+
if left_title_on:
|
|
2141
|
+
ax.set_ylabel(stored_ylabel)
|
|
2142
|
+
else:
|
|
2143
|
+
|
|
2144
|
+
ax.set_ylabel('') # Hidden by user via a5
|
|
2145
|
+
# Store the hidden label for later restoration
|
|
2146
|
+
if stored_ylabel:
|
|
2147
|
+
setattr(ax, '_stored_ylabel', stored_ylabel)
|
|
2148
|
+
|
|
2149
|
+
# Top xlabel (if exists)
|
|
2150
|
+
top_title_on = wasd.get('top', {}).get('title', False)
|
|
2151
|
+
setattr(ax, '_top_xlabel_on', top_title_on)
|
|
2152
|
+
|
|
2153
|
+
# Right ylabel (if exists)
|
|
2154
|
+
right_title_on = wasd.get('right', {}).get('title', False)
|
|
2155
|
+
setattr(ax, '_right_ylabel_on', right_title_on)
|
|
2156
|
+
except Exception as e:
|
|
2157
|
+
# Don't fail session load if WASD restoration fails
|
|
2158
|
+
print(f"Warning: Could not fully restore WASD state: {e}")
|
|
2159
|
+
|
|
2160
|
+
# Rebuild label texts
|
|
2161
|
+
for i, lab in enumerate(labels_list):
|
|
2162
|
+
txt = ax.text(1.0, 1.0, f"{i+1}: {lab}", ha='right', va='top', transform=ax.transAxes,
|
|
2163
|
+
fontsize=plt.rcParams.get('font.size', 16))
|
|
2164
|
+
label_text_objects.append(txt)
|
|
2165
|
+
# Restore curve names visibility
|
|
2166
|
+
try:
|
|
2167
|
+
curve_names_visible = bool(sess.get('curve_names_visible', True))
|
|
2168
|
+
for txt in label_text_objects:
|
|
2169
|
+
txt.set_visible(curve_names_visible)
|
|
2170
|
+
fig._curve_names_visible = curve_names_visible
|
|
2171
|
+
except Exception:
|
|
2172
|
+
pass
|
|
2173
|
+
# Restore stack label position preference
|
|
2174
|
+
try:
|
|
2175
|
+
stack_label_at_bottom = bool(sess.get('stack_label_at_bottom', False))
|
|
2176
|
+
fig._stack_label_at_bottom = stack_label_at_bottom
|
|
2177
|
+
except Exception:
|
|
2178
|
+
pass
|
|
2179
|
+
try:
|
|
2180
|
+
fig._label_anchor_left = bool(sess.get('label_anchor_left', False))
|
|
2181
|
+
except Exception:
|
|
2182
|
+
pass
|
|
2183
|
+
# Restore grid state
|
|
2184
|
+
try:
|
|
2185
|
+
grid_state = bool(sess.get('grid', False))
|
|
2186
|
+
if grid_state:
|
|
2187
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
2188
|
+
else:
|
|
2189
|
+
ax.grid(False)
|
|
2190
|
+
except Exception:
|
|
2191
|
+
pass
|
|
2192
|
+
# CIF tick series (optional)
|
|
2193
|
+
cif_tick_series = sess.get('cif_tick_series') or []
|
|
2194
|
+
cif_hkl_map = {k: [tuple(v) for v in val] for k,val in sess.get('cif_hkl_map', {}).items()}
|
|
2195
|
+
cif_hkl_label_map = {k: dict(v) for k,v in sess.get('cif_hkl_label_map', {}).items()}
|
|
2196
|
+
cif_numbering_enabled = True
|
|
2197
|
+
cif_extend_suspended = False
|
|
2198
|
+
show_cif_hkl = sess.get('show_cif_hkl', False)
|
|
2199
|
+
show_cif_titles = sess.get('show_cif_titles', True)
|
|
2200
|
+
# Provide minimal stubs to satisfy interactive menu dependencies
|
|
2201
|
+
# Axis mode restoration informs downstream toggles (e.g., CIF conversions, crosshair availability)
|
|
2202
|
+
axis_mode_restored = sess.get('axis_mode', 'unknown')
|
|
2203
|
+
use_Q = axis_mode_restored == 'Q'
|
|
2204
|
+
use_r = axis_mode_restored == 'r'
|
|
2205
|
+
use_E = axis_mode_restored == 'energy'
|
|
2206
|
+
use_k = axis_mode_restored == 'k'
|
|
2207
|
+
use_rft = axis_mode_restored == 'rft'
|
|
2208
|
+
use_2th = axis_mode_restored == '2theta'
|
|
2209
|
+
x_label = ax.get_xlabel() or 'X'
|
|
2210
|
+
def update_tick_visibility_local():
|
|
2211
|
+
# Major ticks/labels
|
|
2212
|
+
ax.tick_params(axis='x', bottom=tick_state['bx'], top=tick_state['tx'], labelbottom=tick_state['bx'], labeltop=tick_state['tx'])
|
|
2213
|
+
ax.tick_params(axis='y', left=tick_state['ly'], right=tick_state['ry'], labelleft=tick_state['ly'], labelright=tick_state['ry'])
|
|
2214
|
+
# Minor ticks
|
|
2215
|
+
if tick_state.get('mbx') or tick_state.get('mtx'):
|
|
2216
|
+
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
2217
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
2218
|
+
ax.tick_params(axis='x', which='minor', bottom=tick_state.get('mbx', False), top=tick_state.get('mtx', False), labelbottom=False, labeltop=False)
|
|
2219
|
+
else:
|
|
2220
|
+
ax.tick_params(axis='x', which='minor', bottom=False, top=False, labelbottom=False, labeltop=False)
|
|
2221
|
+
if tick_state.get('mly') or tick_state.get('mry'):
|
|
2222
|
+
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
2223
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
2224
|
+
ax.tick_params(axis='y', which='minor', left=tick_state.get('mly', False), right=tick_state.get('mry', False), labelleft=False, labelright=False)
|
|
2225
|
+
else:
|
|
2226
|
+
ax.tick_params(axis='y', which='minor', left=False, right=False, labelleft=False, labelright=False)
|
|
2227
|
+
update_tick_visibility_local()
|
|
2228
|
+
# Ensure label positions correct
|
|
2229
|
+
stack_label_bottom = bool(sess.get('stack_label_at_bottom', False))
|
|
2230
|
+
update_labels(ax, y_data_list, label_text_objects, saved_stack, stack_label_bottom)
|
|
2231
|
+
if cif_tick_series:
|
|
2232
|
+
# Provide draw/extend helpers compatible with interactive menu using original placement logic
|
|
2233
|
+
def _session_q_to_2theta(peaksQ, wl):
|
|
2234
|
+
if wl is None:
|
|
2235
|
+
return []
|
|
2236
|
+
out = []
|
|
2237
|
+
for q in peaksQ:
|
|
2238
|
+
s = q*wl/(4*np.pi)
|
|
2239
|
+
if 0 <= s < 1:
|
|
2240
|
+
out.append(np.degrees(2*np.arcsin(s)))
|
|
2241
|
+
return out
|
|
2242
|
+
|
|
2243
|
+
def _session_ensure_wavelength(default_wl=1.5406):
|
|
2244
|
+
# Prefer any stored wl, else args.wl, else provided default
|
|
2245
|
+
for _lab,_fname,_peaks,_wl,_qmax,_color in cif_tick_series:
|
|
2246
|
+
if _wl is not None:
|
|
2247
|
+
return _wl
|
|
2248
|
+
return getattr(args, 'wl', None) or default_wl
|
|
2249
|
+
|
|
2250
|
+
def _session_cif_extend(xmax_domain):
|
|
2251
|
+
# Minimal extension: do nothing (could replicate original if needed)
|
|
2252
|
+
return
|
|
2253
|
+
|
|
2254
|
+
def _session_cif_draw():
|
|
2255
|
+
if not cif_tick_series:
|
|
2256
|
+
return
|
|
2257
|
+
try:
|
|
2258
|
+
# Preserve both x and y-axis limits to prevent movement when toggling
|
|
2259
|
+
prev_xlim = ax.get_xlim()
|
|
2260
|
+
prev_ylim = ax.get_ylim()
|
|
2261
|
+
# Use preserved y-axis limits for calculations to prevent incremental movement
|
|
2262
|
+
orig_ylim = prev_ylim
|
|
2263
|
+
orig_yr = orig_ylim[1] - orig_ylim[0]
|
|
2264
|
+
if orig_yr <= 0: orig_yr = 1.0
|
|
2265
|
+
# Check visibility flag first
|
|
2266
|
+
show_titles_local = bool(show_cif_titles) # Use closure variable from outer scope
|
|
2267
|
+
# Also check figure attribute and module attribute as fallback
|
|
2268
|
+
try:
|
|
2269
|
+
# Check figure attribute first (from interactive menu)
|
|
2270
|
+
if hasattr(fig, '_bp_show_cif_titles'):
|
|
2271
|
+
show_titles_local = bool(getattr(fig, '_bp_show_cif_titles', show_titles_local))
|
|
2272
|
+
# Check __main__ module (for backward compatibility)
|
|
2273
|
+
_bp_module = sys.modules.get('__main__')
|
|
2274
|
+
if _bp_module is not None and hasattr(_bp_module, 'show_cif_titles'):
|
|
2275
|
+
show_titles_local = bool(getattr(_bp_module, 'show_cif_titles', show_titles_local))
|
|
2276
|
+
except Exception:
|
|
2277
|
+
pass
|
|
2278
|
+
# Calculate base and spacing based on original y-axis limits
|
|
2279
|
+
if saved_stack or len(y_data_list) > 1:
|
|
2280
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
|
|
2281
|
+
base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
|
|
2282
|
+
else:
|
|
2283
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
|
|
2284
|
+
base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
|
|
2285
|
+
# Only adjust y-axis limits if titles are visible
|
|
2286
|
+
needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
|
|
2287
|
+
cur_ylim = ax.get_ylim()
|
|
2288
|
+
yr = cur_ylim[1] - cur_ylim[0]
|
|
2289
|
+
if yr <= 0: yr = 1.0
|
|
2290
|
+
if show_titles_local and needed_min < orig_ylim[0]:
|
|
2291
|
+
# Expand y-axis only if needed, using original limits as reference
|
|
2292
|
+
ax.set_ylim(needed_min, orig_ylim[1])
|
|
2293
|
+
cur_ylim = ax.get_ylim()
|
|
2294
|
+
yr = cur_ylim[1] - cur_ylim[0]
|
|
2295
|
+
if yr <= 0: yr = 1.0
|
|
2296
|
+
# Recalculate base with new limits if we expanded
|
|
2297
|
+
if saved_stack or len(y_data_list) > 1:
|
|
2298
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
|
|
2299
|
+
base = global_min - 0.08*yr; spacing = 0.05*yr
|
|
2300
|
+
else:
|
|
2301
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
|
|
2302
|
+
base = global_min - 0.06*yr; spacing = 0.04*yr
|
|
2303
|
+
# Clear previous artifacts
|
|
2304
|
+
for art in getattr(ax, '_cif_tick_art', []):
|
|
2305
|
+
try: art.remove()
|
|
2306
|
+
except Exception: pass
|
|
2307
|
+
new_art = []
|
|
2308
|
+
show_hkl_local = bool(show_cif_hkl)
|
|
2309
|
+
wl_any = _session_ensure_wavelength()
|
|
2310
|
+
# Draw each series
|
|
2311
|
+
for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
|
|
2312
|
+
y_line = base - i*spacing
|
|
2313
|
+
# Convert peaks to axis domain
|
|
2314
|
+
if use_2th:
|
|
2315
|
+
wl_use = wl if wl is not None else wl_any
|
|
2316
|
+
domain_peaks = _session_q_to_2theta(peaksQ, wl_use)
|
|
2317
|
+
else:
|
|
2318
|
+
domain_peaks = peaksQ
|
|
2319
|
+
# Clip to visible x-range
|
|
2320
|
+
xlow,xhigh = ax.get_xlim()
|
|
2321
|
+
domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
|
|
2322
|
+
# Build hkl label map (keys are Q values, not 2θ)
|
|
2323
|
+
label_map = cif_hkl_label_map.get(fname, {}) if show_hkl_local else {}
|
|
2324
|
+
if show_hkl_local and len(domain_peaks) > 4000:
|
|
2325
|
+
show_hkl_local = False # safety
|
|
2326
|
+
for p in domain_peaks:
|
|
2327
|
+
ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
|
|
2328
|
+
new_art.append(ln)
|
|
2329
|
+
if show_hkl_local:
|
|
2330
|
+
# When axis is 2θ convert back to Q to look up hkl label
|
|
2331
|
+
if use_2th and (wl or wl_any):
|
|
2332
|
+
theta = np.radians(p/2.0)
|
|
2333
|
+
Qp = 4*np.pi*np.sin(theta)/(wl if wl is not None else wl_any)
|
|
2334
|
+
else:
|
|
2335
|
+
Qp = p
|
|
2336
|
+
lbl = label_map.get(round(Qp,6))
|
|
2337
|
+
if lbl:
|
|
2338
|
+
t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
|
|
2339
|
+
new_art.append(t_hkl)
|
|
2340
|
+
# Removed numbering prefix; keep one leading space for padding from axis
|
|
2341
|
+
# Only add title label if show_cif_titles is True
|
|
2342
|
+
if show_titles_local:
|
|
2343
|
+
label_text = f" {lab}"
|
|
2344
|
+
txt = ax.text(prev_xlim[0], y_line+0.005*yr, label_text,
|
|
2345
|
+
ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',16))), color=color)
|
|
2346
|
+
new_art.append(txt)
|
|
2347
|
+
ax._cif_tick_art = new_art
|
|
2348
|
+
# Restore both x and y-axis limits to prevent movement
|
|
2349
|
+
ax.set_xlim(prev_xlim)
|
|
2350
|
+
# Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
|
|
2351
|
+
if not show_titles_local:
|
|
2352
|
+
# Titles hidden: always restore original limits
|
|
2353
|
+
ax.set_ylim(prev_ylim)
|
|
2354
|
+
elif needed_min >= prev_ylim[0]:
|
|
2355
|
+
# Titles shown but no expansion needed: restore original limits
|
|
2356
|
+
ax.set_ylim(prev_ylim)
|
|
2357
|
+
# Otherwise, keep the expanded limits (already set above)
|
|
2358
|
+
fig.canvas.draw_idle()
|
|
2359
|
+
except Exception:
|
|
2360
|
+
pass
|
|
2361
|
+
ax._cif_extend_func = _session_cif_extend
|
|
2362
|
+
ax._cif_draw_func = _session_cif_draw
|
|
2363
|
+
ax._cif_draw_func()
|
|
2364
|
+
|
|
2365
|
+
# Restore axis title duplicates/visibility exactly as saved
|
|
2366
|
+
titles = sess.get('axis_titles', {})
|
|
2367
|
+
title_texts = sess.get('axis_title_texts', {})
|
|
2368
|
+
bottom_text = title_texts.get('bottom_x') or title_texts.get('bottom')
|
|
2369
|
+
left_text = title_texts.get('left_y') or title_texts.get('left')
|
|
2370
|
+
top_text = title_texts.get('top_x') or title_texts.get('top')
|
|
2371
|
+
right_text = title_texts.get('right_y') or title_texts.get('right')
|
|
2372
|
+
try:
|
|
2373
|
+
if bottom_text is not None:
|
|
2374
|
+
ax._stored_xlabel = bottom_text
|
|
2375
|
+
if left_text is not None:
|
|
2376
|
+
ax._stored_ylabel = left_text
|
|
2377
|
+
if top_text:
|
|
2378
|
+
ax._top_xlabel_text_override = top_text
|
|
2379
|
+
elif hasattr(ax, '_top_xlabel_text_override'):
|
|
2380
|
+
delattr(ax, '_top_xlabel_text_override')
|
|
2381
|
+
if right_text:
|
|
2382
|
+
ax._right_ylabel_text_override = right_text
|
|
2383
|
+
elif hasattr(ax, '_right_ylabel_text_override'):
|
|
2384
|
+
delattr(ax, '_right_ylabel_text_override')
|
|
2385
|
+
# Bottom X title
|
|
2386
|
+
if titles.get('has_bottom_x') is False:
|
|
2387
|
+
ax.xaxis.label.set_visible(False)
|
|
2388
|
+
else:
|
|
2389
|
+
ax.xaxis.label.set_visible(True)
|
|
2390
|
+
if bottom_text is not None:
|
|
2391
|
+
ax.set_xlabel(bottom_text)
|
|
2392
|
+
elif hasattr(ax, '_stored_xlabel'):
|
|
2393
|
+
ax.set_xlabel(ax._stored_xlabel)
|
|
2394
|
+
try:
|
|
2395
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
2396
|
+
except Exception:
|
|
2397
|
+
pass
|
|
2398
|
+
# Left Y title
|
|
2399
|
+
if titles.get('has_left_y') is False:
|
|
2400
|
+
ax.yaxis.label.set_visible(False)
|
|
2401
|
+
else:
|
|
2402
|
+
ax.yaxis.label.set_visible(True)
|
|
2403
|
+
if left_text is not None:
|
|
2404
|
+
ax.set_ylabel(left_text)
|
|
2405
|
+
elif hasattr(ax, '_stored_ylabel'):
|
|
2406
|
+
ax.set_ylabel(ax._stored_ylabel)
|
|
2407
|
+
try:
|
|
2408
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
2409
|
+
except Exception:
|
|
2410
|
+
pass
|
|
2411
|
+
# Top X duplicate
|
|
2412
|
+
ax._top_xlabel_on = bool(titles.get('top_x', False))
|
|
2413
|
+
try:
|
|
2414
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
2415
|
+
except Exception:
|
|
2416
|
+
pass
|
|
2417
|
+
if not ax._top_xlabel_on and hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
|
|
2418
|
+
try:
|
|
2419
|
+
ax._top_xlabel_artist.set_visible(False)
|
|
2420
|
+
except Exception:
|
|
2421
|
+
pass
|
|
2422
|
+
# Right Y duplicate
|
|
2423
|
+
ax._right_ylabel_on = bool(titles.get('right_y', False))
|
|
2424
|
+
try:
|
|
2425
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
2426
|
+
except Exception:
|
|
2427
|
+
pass
|
|
2428
|
+
if not ax._right_ylabel_on and hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
|
|
2429
|
+
try:
|
|
2430
|
+
ax._right_ylabel_artist.set_visible(False)
|
|
2431
|
+
except Exception:
|
|
2432
|
+
pass
|
|
2433
|
+
except Exception:
|
|
2434
|
+
pass
|
|
2435
|
+
# Always open interactive menu for session files
|
|
2436
|
+
try:
|
|
2437
|
+
args.stack = saved_stack
|
|
2438
|
+
except Exception:
|
|
2439
|
+
pass
|
|
2440
|
+
# Restore autoscale/raw flags for consistent behavior with saved session
|
|
2441
|
+
try:
|
|
2442
|
+
args_subset = sess.get('args_subset', {})
|
|
2443
|
+
if 'autoscale' in args_subset:
|
|
2444
|
+
args.autoscale = bool(args_subset['autoscale'])
|
|
2445
|
+
if 'norm' in args_subset:
|
|
2446
|
+
args.norm = bool(args_subset['norm'])
|
|
2447
|
+
except Exception:
|
|
2448
|
+
pass
|
|
2449
|
+
try:
|
|
2450
|
+
plt.ion()
|
|
2451
|
+
except Exception:
|
|
2452
|
+
pass
|
|
2453
|
+
try:
|
|
2454
|
+
plt.show(block=False)
|
|
2455
|
+
except Exception:
|
|
2456
|
+
pass
|
|
2457
|
+
|
|
2458
|
+
# CRITICAL: Disable automatic layout adjustments to ensure parameter independence
|
|
2459
|
+
# This prevents matplotlib from moving axes when labels are changed
|
|
2460
|
+
try:
|
|
2461
|
+
fig.set_layout_engine('none')
|
|
2462
|
+
except AttributeError:
|
|
2463
|
+
# Older matplotlib versions - disable tight_layout
|
|
2464
|
+
try:
|
|
2465
|
+
fig.set_tight_layout(False)
|
|
2466
|
+
except Exception:
|
|
2467
|
+
pass
|
|
2468
|
+
|
|
2469
|
+
interactive_menu(fig, ax, y_data_list, x_data_list, labels_list,
|
|
2470
|
+
orig_y, label_text_objects, delta, x_label, args,
|
|
2471
|
+
x_full_list, raw_y_full_list, offsets_list,
|
|
2472
|
+
use_Q, use_r, use_E, use_k, use_rft)
|
|
2473
|
+
plt.show()
|
|
2474
|
+
exit()
|
|
2475
|
+
|
|
2476
|
+
# ---------------- Handle conversion ----------------
|
|
2477
|
+
if args.convert:
|
|
2478
|
+
if args.wl is None:
|
|
2479
|
+
print("Error: --wl is required for --convert")
|
|
2480
|
+
|
|
2481
|
+
exit(1)
|
|
2482
|
+
convert_to_qye(args.convert, args.wl)
|
|
2483
|
+
exit()
|
|
2484
|
+
|
|
2485
|
+
# ---------------- Plotting ----------------
|
|
2486
|
+
offset = 0.0
|
|
2487
|
+
direction = -1 if args.stack else 1 # stack downward
|
|
2488
|
+
if args.interactive:
|
|
2489
|
+
plt.ion()
|
|
2490
|
+
fig, ax = plt.subplots(figsize=(8, 6))
|
|
2491
|
+
|
|
2492
|
+
y_data_list = []
|
|
2493
|
+
x_data_list = []
|
|
2494
|
+
labels_list = []
|
|
2495
|
+
orig_y = []
|
|
2496
|
+
label_text_objects = []
|
|
2497
|
+
# New lists to preserve full data & offsets
|
|
2498
|
+
x_full_list = []
|
|
2499
|
+
raw_y_full_list = []
|
|
2500
|
+
offsets_list = []
|
|
2501
|
+
|
|
2502
|
+
# ---------------- Determine X-axis type ----------------
|
|
2503
|
+
def _ext_token(path):
|
|
2504
|
+
return os.path.splitext(path)[1].lower() # includes leading dot
|
|
2505
|
+
|
|
2506
|
+
# Check for CSV/MPT files with --xaxis time
|
|
2507
|
+
any_csv = any(f.lower().endswith((".csv", ".mpt")) for f in args.files)
|
|
2508
|
+
use_time_mode = any_csv and args.xaxis and args.xaxis.lower() == "time"
|
|
2509
|
+
|
|
2510
|
+
if use_time_mode:
|
|
2511
|
+
# Special mode: plot time (h) vs voltage (V) for electrochemistry CSV/MPT files
|
|
2512
|
+
axis_mode = "time"
|
|
2513
|
+
else:
|
|
2514
|
+
# Regular XRD/PDF/XAS mode - proceed with normal detection
|
|
2515
|
+
any_qye = any(f.lower().endswith(".qye") for f in args.files)
|
|
2516
|
+
any_gr = any(f.lower().endswith(".gr") for f in args.files)
|
|
2517
|
+
any_nor = any(f.lower().endswith(".nor") for f in args.files)
|
|
2518
|
+
any_chik = any("chik" in _ext_token(f) for f in args.files)
|
|
2519
|
+
any_chir = any("chir" in _ext_token(f) for f in args.files)
|
|
2520
|
+
any_txt = any(f.lower().endswith(".txt") for f in args.files)
|
|
2521
|
+
any_cif = any(f.lower().endswith(".cif") for f in args.files)
|
|
2522
|
+
non_cif_count = sum(0 if f.lower().endswith('.cif') else 1 for f in args.files)
|
|
2523
|
+
cif_only = any_cif and non_cif_count == 0
|
|
2524
|
+
any_lambda = any(":" in f for f in args.files) or args.wl is not None
|
|
2525
|
+
|
|
2526
|
+
# Incompatibilities (no mixing of fundamentally different axis domains)
|
|
2527
|
+
if sum(bool(x) for x in (any_gr, any_nor, any_chik, any_chir, (any_qye or any_lambda or any_cif))) > 1:
|
|
2528
|
+
raise ValueError("Cannot mix .gr (r), .nor (energy), .chik (k), .chir (FT-EXAFS R), and Q/2θ/CIF data together. Split runs.")
|
|
2529
|
+
|
|
2530
|
+
# Automatic axis selection based on file extensions
|
|
2531
|
+
if any_qye:
|
|
2532
|
+
axis_mode = "Q"
|
|
2533
|
+
elif any_gr:
|
|
2534
|
+
axis_mode = "r"
|
|
2535
|
+
elif any_nor:
|
|
2536
|
+
axis_mode = "energy"
|
|
2537
|
+
elif any_chik:
|
|
2538
|
+
axis_mode = "k"
|
|
2539
|
+
elif any_chir:
|
|
2540
|
+
axis_mode = "rft"
|
|
2541
|
+
elif any_txt:
|
|
2542
|
+
# .txt is generic, require --xaxis
|
|
2543
|
+
if args.xaxis:
|
|
2544
|
+
axis_mode = args.xaxis
|
|
2545
|
+
else:
|
|
2546
|
+
raise ValueError("Unknown file type. Use: batplot file.txt --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
|
|
2547
|
+
elif any_lambda or any_cif:
|
|
2548
|
+
if args.xaxis and args.xaxis.lower() in ("2theta","two_theta","tth"):
|
|
2549
|
+
axis_mode = "2theta"
|
|
2550
|
+
else:
|
|
2551
|
+
# If wavelength is provided, user wants to convert to Q
|
|
2552
|
+
# CIF files are in Q space
|
|
2553
|
+
axis_mode = "Q"
|
|
2554
|
+
elif args.xaxis:
|
|
2555
|
+
axis_mode = args.xaxis
|
|
2556
|
+
else:
|
|
2557
|
+
raise ValueError("Unknown file type. Use: batplot file.csv --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
|
|
2558
|
+
|
|
2559
|
+
use_Q = axis_mode == "Q"
|
|
2560
|
+
use_2th = axis_mode == "2theta"
|
|
2561
|
+
use_r = axis_mode == "r"
|
|
2562
|
+
use_E = axis_mode == "energy"
|
|
2563
|
+
use_k = axis_mode == "k" # NEW
|
|
2564
|
+
use_rft = axis_mode == "rft" # NEW
|
|
2565
|
+
use_time = axis_mode == "time" # NEW: electrochemistry time mode
|
|
2566
|
+
|
|
2567
|
+
# Initialize wavelength_file from args.wl (may be overridden per-file later)
|
|
2568
|
+
wavelength_file = getattr(args, 'wl', None)
|
|
2569
|
+
|
|
2570
|
+
# Validate: if using 2theta mode with CIF files, wavelength is required
|
|
2571
|
+
if use_2th and any_cif and not wavelength_file:
|
|
2572
|
+
raise ValueError(
|
|
2573
|
+
"Cannot display CIF files in 2θ mode without wavelength.\n"
|
|
2574
|
+
"Please provide wavelength using:\n"
|
|
2575
|
+
" --wl <wavelength_in_angstrom>\n"
|
|
2576
|
+
" or include wavelength in filename (e.g., data_wl1.5406.xy)\n"
|
|
2577
|
+
" or use Q mode (remove --xaxis 2theta)"
|
|
2578
|
+
)
|
|
2579
|
+
|
|
2580
|
+
# ---------------- Read and plot files ----------------
|
|
2581
|
+
# Helper to extract discrete peak positions from a simulated CIF pattern by local maxima picking
|
|
2582
|
+
def _extract_peak_positions(Q_array, I_array, min_rel_height=0.05):
|
|
2583
|
+
if Q_array.size == 0 or I_array.size == 0:
|
|
2584
|
+
return []
|
|
2585
|
+
Imax = I_array.max() if I_array.size else 0
|
|
2586
|
+
if Imax <= 0:
|
|
2587
|
+
return []
|
|
2588
|
+
thr = Imax * min_rel_height
|
|
2589
|
+
peaks = []
|
|
2590
|
+
for i in range(1, len(I_array)-1):
|
|
2591
|
+
if I_array[i] >= thr and I_array[i] >= I_array[i-1] and I_array[i] >= I_array[i+1]:
|
|
2592
|
+
# simple peak refine by local quadratic (optional)
|
|
2593
|
+
y1,y2,y3 = I_array[i-1], I_array[i], I_array[i+1]
|
|
2594
|
+
x1,x2,x3 = Q_array[i-1], Q_array[i], Q_array[i+1]
|
|
2595
|
+
denom = (y1 - 2*y2 + y3)
|
|
2596
|
+
if abs(denom) > 1e-12:
|
|
2597
|
+
dx = 0.5*(y1 - y3)/denom
|
|
2598
|
+
if -0.6 < dx < 0.6:
|
|
2599
|
+
xc = x2 + dx*(x3 - x1)/2.0
|
|
2600
|
+
if Q_array[0] <= xc <= Q_array[-1]:
|
|
2601
|
+
peaks.append(xc)
|
|
2602
|
+
continue
|
|
2603
|
+
peaks.append(Q_array[i])
|
|
2604
|
+
return peaks
|
|
2605
|
+
|
|
2606
|
+
# Will accumulate CIF tick series to render after main curves
|
|
2607
|
+
cif_tick_series = [] # list of (label, filename, peak_positions_Q, wavelength_or_None, qmax_simulated, color)
|
|
2608
|
+
cif_hkl_map = {} # filename -> list of (Q,h,k,l)
|
|
2609
|
+
cif_hkl_label_map = {} # filename -> dict of Q -> label string
|
|
2610
|
+
cif_numbering_enabled = True # show numbering for CIF tick sets (mixed mode only)
|
|
2611
|
+
cif_extend_suspended = False # guard flag to prevent auto extension during certain operations
|
|
2612
|
+
QUIET_CIF_EXTEND = True # suppress extension debug output
|
|
2613
|
+
|
|
2614
|
+
# Cached wavelength for CIF tick conversion (prevents interactive blocking prompts)
|
|
2615
|
+
cif_cached_wavelength = None
|
|
2616
|
+
show_cif_hkl = False
|
|
2617
|
+
show_cif_titles = True # show CIF filename labels by default
|
|
2618
|
+
|
|
2619
|
+
# Store wavelength info per file for crosshair display
|
|
2620
|
+
file_wavelength_info = [] # List of dicts: {'original_wl': float or None, 'conversion_wl': float or None}
|
|
2621
|
+
|
|
2622
|
+
# Separate style files from data files
|
|
2623
|
+
data_files = []
|
|
2624
|
+
style_file_path = None
|
|
2625
|
+
for f in args.files:
|
|
2626
|
+
ext = os.path.splitext(f)[1].lower()
|
|
2627
|
+
if ext in ('.bps', '.bpsg', '.bpcfg'):
|
|
2628
|
+
if style_file_path is None:
|
|
2629
|
+
style_file_path = f
|
|
2630
|
+
else:
|
|
2631
|
+
print(f"Warning: Multiple style files provided, using first: {style_file_path}")
|
|
2632
|
+
else:
|
|
2633
|
+
data_files.append(f)
|
|
2634
|
+
|
|
2635
|
+
# If no data files remain, exit
|
|
2636
|
+
if not data_files:
|
|
2637
|
+
print("No data files found (only style files provided).")
|
|
2638
|
+
exit(1)
|
|
2639
|
+
|
|
2640
|
+
# Load style file if provided
|
|
2641
|
+
style_cfg = None
|
|
2642
|
+
if style_file_path:
|
|
2643
|
+
if not os.path.isfile(style_file_path):
|
|
2644
|
+
print(f"Warning: Style file not found: {style_file_path}")
|
|
2645
|
+
else:
|
|
2646
|
+
try:
|
|
2647
|
+
with open(style_file_path, 'r', encoding='utf-8') as f:
|
|
2648
|
+
style_cfg = json.load(f)
|
|
2649
|
+
print(f"Using style file: {os.path.basename(style_file_path)}")
|
|
2650
|
+
except Exception as e:
|
|
2651
|
+
print(f"Warning: Could not load style file {style_file_path}: {e}")
|
|
2652
|
+
|
|
2653
|
+
# Use data_files instead of args.files for processing
|
|
2654
|
+
for idx_file, file_entry in enumerate(data_files):
|
|
2655
|
+
parts = file_entry.split(":")
|
|
2656
|
+
fname = parts[0]
|
|
2657
|
+
# Parse wavelength parameters: file:wl1 or file:wl1:wl2 or file.cif:wl
|
|
2658
|
+
wavelength_file = None
|
|
2659
|
+
original_wavelength = None # First wavelength (for Q conversion)
|
|
2660
|
+
conversion_wavelength = None # Second wavelength (for 2theta conversion back)
|
|
2661
|
+
if len(parts) == 2:
|
|
2662
|
+
# Single wavelength: file:wl or file.cif:wl
|
|
2663
|
+
try:
|
|
2664
|
+
wavelength_file = float(parts[1])
|
|
2665
|
+
original_wavelength = wavelength_file
|
|
2666
|
+
except ValueError:
|
|
2667
|
+
pass
|
|
2668
|
+
elif len(parts) == 3:
|
|
2669
|
+
# Dual wavelength: file:wl1:wl2
|
|
2670
|
+
try:
|
|
2671
|
+
original_wavelength = float(parts[1])
|
|
2672
|
+
conversion_wavelength = float(parts[2])
|
|
2673
|
+
wavelength_file = conversion_wavelength # Use second for final conversion
|
|
2674
|
+
except ValueError:
|
|
2675
|
+
pass
|
|
2676
|
+
if wavelength_file is None:
|
|
2677
|
+
wavelength_file = args.wl
|
|
2678
|
+
if not os.path.isfile(fname):
|
|
2679
|
+
print(f"File not found: {fname}")
|
|
2680
|
+
continue
|
|
2681
|
+
file_ext = os.path.splitext(fname)[1].lower()
|
|
2682
|
+
is_chik = "chik" in file_ext
|
|
2683
|
+
is_chir = "chir" in file_ext
|
|
2684
|
+
is_cif = file_ext == '.cif'
|
|
2685
|
+
label = os.path.basename(fname)
|
|
2686
|
+
if wavelength_file and not use_r and not use_E and file_ext not in (".gr", ".nor", ".cif"):
|
|
2687
|
+
if conversion_wavelength is not None:
|
|
2688
|
+
label += f" (λ₁={original_wavelength:.5f}→λ₂={conversion_wavelength:.5f} Å)"
|
|
2689
|
+
else:
|
|
2690
|
+
label += f" (λ={wavelength_file:.5f} Å)"
|
|
2691
|
+
# Store wavelength info for this file
|
|
2692
|
+
file_wavelength_info.append({
|
|
2693
|
+
'original_wl': original_wavelength,
|
|
2694
|
+
'conversion_wl': conversion_wavelength,
|
|
2695
|
+
'final_wl': wavelength_file
|
|
2696
|
+
})
|
|
2697
|
+
|
|
2698
|
+
# ---- Read data (time mode for CSV/MPT or regular mode) ----
|
|
2699
|
+
if use_time and file_ext in ('.csv', '.mpt'):
|
|
2700
|
+
# Time mode: read time (h) vs voltage (V) for electrochemistry files
|
|
2701
|
+
try:
|
|
2702
|
+
if file_ext == '.csv':
|
|
2703
|
+
x, y = read_csv_time_voltage(fname)
|
|
2704
|
+
elif file_ext == '.mpt':
|
|
2705
|
+
x, y = read_mpt_time_voltage(fname)
|
|
2706
|
+
e = None
|
|
2707
|
+
except Exception as e_read:
|
|
2708
|
+
print(f"Error reading {fname} in time mode: {e_read}")
|
|
2709
|
+
continue
|
|
2710
|
+
elif is_cif:
|
|
2711
|
+
try:
|
|
2712
|
+
# Simulate pattern directly in Q space regardless of current axis_mode
|
|
2713
|
+
Q_sim, I_sim = simulate_cif_pattern_Q(fname)
|
|
2714
|
+
x = Q_sim
|
|
2715
|
+
y = I_sim
|
|
2716
|
+
e = None
|
|
2717
|
+
# Force axis mode if needed
|
|
2718
|
+
if not (use_Q or use_2th):
|
|
2719
|
+
use_Q = True
|
|
2720
|
+
# Reflection list and per-Q hkl labels (no wavelength cutoff in pure Q domain)
|
|
2721
|
+
qmax_sim = float(Q_sim[-1]) if len(Q_sim) else 0.0
|
|
2722
|
+
refl = cif_reflection_positions(fname, Qmax=qmax_sim, wavelength=None)
|
|
2723
|
+
hkl_list = list_reflections_with_hkl(fname, Qmax=qmax_sim, wavelength=None)
|
|
2724
|
+
cif_hkl_label_map[fname] = build_hkl_label_map_from_list(hkl_list)
|
|
2725
|
+
# Store wavelength for CIF ticks: use provided wavelength if in 2theta mode
|
|
2726
|
+
cif_wl = wavelength_file if use_2th and wavelength_file else None
|
|
2727
|
+
# default tick color black
|
|
2728
|
+
cif_tick_series.append((label, fname, refl, cif_wl, qmax_sim, 'k'))
|
|
2729
|
+
# If CIF mixed with other data types, do NOT plot intensity curve (ticks only)
|
|
2730
|
+
if not cif_only:
|
|
2731
|
+
continue # skip rest of loop so curve isn't added
|
|
2732
|
+
except Exception as e_read:
|
|
2733
|
+
print(f"Error simulating CIF {fname}: {e_read}")
|
|
2734
|
+
continue
|
|
2735
|
+
elif file_ext == ".gr":
|
|
2736
|
+
try:
|
|
2737
|
+
x, y = read_gr_file(fname); e = None
|
|
2738
|
+
except Exception as e_read:
|
|
2739
|
+
print(f"Error reading {fname}: {e_read}"); continue
|
|
2740
|
+
elif file_ext in [".nor", ".xy", ".xye", ".qye", ".dat", ".csv"] or is_chik or is_chir:
|
|
2741
|
+
try:
|
|
2742
|
+
data = robust_loadtxt_skipheader(fname)
|
|
2743
|
+
except Exception as e_read:
|
|
2744
|
+
print(f"Error reading {fname}: {e_read}"); continue
|
|
2745
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
2746
|
+
if data.shape[1] < 2:
|
|
2747
|
+
print(f"Invalid data format in {fname}"); continue
|
|
2748
|
+
# Handle --readcol flag to select specific columns
|
|
2749
|
+
# Check for extension-specific readcol first, then fall back to general --readcol
|
|
2750
|
+
readcol_spec = None
|
|
2751
|
+
if hasattr(args, 'readcol_by_ext') and file_ext in args.readcol_by_ext:
|
|
2752
|
+
readcol_spec = args.readcol_by_ext[file_ext]
|
|
2753
|
+
elif args.readcol:
|
|
2754
|
+
readcol_spec = args.readcol
|
|
2755
|
+
|
|
2756
|
+
if readcol_spec:
|
|
2757
|
+
x_col, y_col = readcol_spec
|
|
2758
|
+
# Convert from 1-indexed to 0-indexed
|
|
2759
|
+
x_col_idx = x_col - 1
|
|
2760
|
+
y_col_idx = y_col - 1
|
|
2761
|
+
if x_col_idx < 0 or x_col_idx >= data.shape[1]:
|
|
2762
|
+
print(f"Error: X column {x_col} out of range in {fname} (has {data.shape[1]} columns)")
|
|
2763
|
+
continue
|
|
2764
|
+
if y_col_idx < 0 or y_col_idx >= data.shape[1]:
|
|
2765
|
+
print(f"Error: Y column {y_col} out of range in {fname} (has {data.shape[1]} columns)")
|
|
2766
|
+
continue
|
|
2767
|
+
x, y = data[:, x_col_idx], data[:, y_col_idx]
|
|
2768
|
+
e = None # Error bars not supported with custom column selection
|
|
2769
|
+
else:
|
|
2770
|
+
x, y = data[:, 0], data[:, 1]
|
|
2771
|
+
e = data[:, 2] if data.shape[1] >= 3 else None
|
|
2772
|
+
# For .csv, .dat, .xy, .xye, .qye, .nor, .chik, .chir, this robustly skips headers
|
|
2773
|
+
elif args.fullprof and file_ext == ".dat":
|
|
2774
|
+
try:
|
|
2775
|
+
y_plot, n_rows = read_fullprof_rowwise(fname)
|
|
2776
|
+
xstart, xend, xstep = args.fullprof[0], args.fullprof[1], args.fullprof[2]
|
|
2777
|
+
x_plot = np.linspace(xstart, xend, len(y_plot))
|
|
2778
|
+
wavelength = args.fullprof[3] if len(args.fullprof)>=4 else wavelength_file
|
|
2779
|
+
if use_Q and wavelength:
|
|
2780
|
+
theta_rad = np.radians(x_plot / 2)
|
|
2781
|
+
x_plot = 4*np.pi*np.sin(theta_rad)/wavelength
|
|
2782
|
+
e_plot = None
|
|
2783
|
+
except Exception as e:
|
|
2784
|
+
print(f"Error reading FullProf-style {fname}: {e}")
|
|
2785
|
+
continue
|
|
2786
|
+
else:
|
|
2787
|
+
# Unknown extension: attempt to read as 2-column (x, y) data
|
|
2788
|
+
try:
|
|
2789
|
+
data = robust_loadtxt_skipheader(fname)
|
|
2790
|
+
except Exception as e_read:
|
|
2791
|
+
print(f"Error reading {fname} (unknown extension '{file_ext}'): {e_read}")
|
|
2792
|
+
continue
|
|
2793
|
+
if data.ndim == 1: data = data.reshape(1, -1)
|
|
2794
|
+
if data.shape[1] < 2:
|
|
2795
|
+
print(f"Invalid data format in {fname}: expected at least 2 columns, got {data.shape[1]}")
|
|
2796
|
+
continue
|
|
2797
|
+
# Handle --readcol flag to select specific columns
|
|
2798
|
+
# Check for extension-specific readcol first, then fall back to general --readcol
|
|
2799
|
+
readcol_spec = None
|
|
2800
|
+
if hasattr(args, 'readcol_by_ext') and file_ext in args.readcol_by_ext:
|
|
2801
|
+
readcol_spec = args.readcol_by_ext[file_ext]
|
|
2802
|
+
elif args.readcol:
|
|
2803
|
+
readcol_spec = args.readcol
|
|
2804
|
+
|
|
2805
|
+
if readcol_spec:
|
|
2806
|
+
x_col, y_col = readcol_spec
|
|
2807
|
+
# Convert from 1-indexed to 0-indexed
|
|
2808
|
+
x_col_idx = x_col - 1
|
|
2809
|
+
y_col_idx = y_col - 1
|
|
2810
|
+
if x_col_idx < 0 or x_col_idx >= data.shape[1]:
|
|
2811
|
+
print(f"Error: X column {x_col} out of range in {fname} (has {data.shape[1]} columns)")
|
|
2812
|
+
continue
|
|
2813
|
+
if y_col_idx < 0 or y_col_idx >= data.shape[1]:
|
|
2814
|
+
print(f"Error: Y column {y_col} out of range in {fname} (has {data.shape[1]} columns)")
|
|
2815
|
+
continue
|
|
2816
|
+
x, y = data[:, x_col_idx], data[:, y_col_idx]
|
|
2817
|
+
e = None # Error bars not supported with custom column selection
|
|
2818
|
+
else:
|
|
2819
|
+
x, y = data[:, 0], data[:, 1]
|
|
2820
|
+
e = data[:, 2] if data.shape[1] >= 3 else None
|
|
2821
|
+
# Warn once per unknown extension type
|
|
2822
|
+
if not hasattr(args, '_warned_extensions'):
|
|
2823
|
+
args._warned_extensions = set()
|
|
2824
|
+
if file_ext and file_ext not in args._warned_extensions:
|
|
2825
|
+
args._warned_extensions.add(file_ext)
|
|
2826
|
+
print(f"Note: Reading '{file_ext}' file as 2-column (x, y) data. Use --xaxis to specify x-axis type if needed.")
|
|
2827
|
+
|
|
2828
|
+
# ---- X-axis conversion logic updated (no conversion for energy or time) ----
|
|
2829
|
+
if use_time:
|
|
2830
|
+
# Time mode: data already in hours, no conversion needed
|
|
2831
|
+
x_plot = x
|
|
2832
|
+
elif use_2th and original_wavelength is not None and conversion_wavelength is not None:
|
|
2833
|
+
# Dual wavelength conversion: 2theta -> Q (wl1) -> 2theta (wl2)
|
|
2834
|
+
# Step 1: Convert original 2theta to Q using first wavelength
|
|
2835
|
+
theta_rad = np.radians(x / 2.0)
|
|
2836
|
+
Q = 4 * np.pi * np.sin(theta_rad) / original_wavelength
|
|
2837
|
+
# Step 2: Convert Q back to 2theta using second wavelength
|
|
2838
|
+
# Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
|
|
2839
|
+
sin_theta = Q * conversion_wavelength / (4 * np.pi)
|
|
2840
|
+
# Clamp to valid range [-1, 1]
|
|
2841
|
+
sin_theta = np.clip(sin_theta, -1.0, 1.0)
|
|
2842
|
+
theta_new_rad = np.arcsin(sin_theta)
|
|
2843
|
+
x_plot = np.degrees(2 * theta_new_rad)
|
|
2844
|
+
elif use_2th and file_ext == ".qye" and wavelength_file:
|
|
2845
|
+
# Convert Q to 2theta for .qye files when wavelength is provided
|
|
2846
|
+
# Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
|
|
2847
|
+
sin_theta = x * wavelength_file / (4 * np.pi)
|
|
2848
|
+
# Clamp to valid range [-1, 1]
|
|
2849
|
+
sin_theta = np.clip(sin_theta, -1.0, 1.0)
|
|
2850
|
+
theta_rad = np.arcsin(sin_theta)
|
|
2851
|
+
x_plot = np.degrees(2 * theta_rad)
|
|
2852
|
+
elif use_Q and file_ext not in (".qye", ".gr", ".nor"):
|
|
2853
|
+
if original_wavelength is not None:
|
|
2854
|
+
# Use first wavelength for Q conversion
|
|
2855
|
+
theta_rad = np.radians(x/2)
|
|
2856
|
+
x_plot = 4*np.pi*np.sin(theta_rad)/original_wavelength
|
|
2857
|
+
elif wavelength_file:
|
|
2858
|
+
theta_rad = np.radians(x/2)
|
|
2859
|
+
x_plot = 4*np.pi*np.sin(theta_rad)/wavelength_file
|
|
2860
|
+
else:
|
|
2861
|
+
x_plot = x
|
|
2862
|
+
else:
|
|
2863
|
+
# r, energy, k, rft, or already Q: direct
|
|
2864
|
+
x_plot = x
|
|
2865
|
+
|
|
2866
|
+
# ---- Store full (converted) arrays BEFORE cropping ----
|
|
2867
|
+
x_full = x_plot.copy()
|
|
2868
|
+
y_full_raw = y.copy()
|
|
2869
|
+
raw_y_full_list.append(y_full_raw)
|
|
2870
|
+
x_full_list.append(x_full)
|
|
2871
|
+
|
|
2872
|
+
# ---- Apply xrange (for initial display only; full data kept above) ----
|
|
2873
|
+
y_plot = y_full_raw
|
|
2874
|
+
e_plot = e
|
|
2875
|
+
if args.xrange:
|
|
2876
|
+
mask = (x_full>=args.xrange[0]) & (x_full<=args.xrange[1])
|
|
2877
|
+
ax.set_xlim(args.xrange[0], args.xrange[1])
|
|
2878
|
+
x_plot = x_full[mask]
|
|
2879
|
+
y_plot = y_full_raw[mask]
|
|
2880
|
+
if e_plot is not None:
|
|
2881
|
+
e_plot = e_plot[mask]
|
|
2882
|
+
else:
|
|
2883
|
+
x_plot = x_full
|
|
2884
|
+
|
|
2885
|
+
# ---- Apply EXAFS k-weighting transformation if requested ----
|
|
2886
|
+
if getattr(args, 'k3chik', False):
|
|
2887
|
+
# Multiply y by x³ for EXAFS k³χ(k) plots
|
|
2888
|
+
y_plot = y_plot * (x_plot ** 3)
|
|
2889
|
+
y_full_raw = y_full_raw * (x_full ** 3)
|
|
2890
|
+
raw_y_full_list[-1] = y_full_raw
|
|
2891
|
+
elif getattr(args, 'k2chik', False):
|
|
2892
|
+
# Multiply y by x² for EXAFS k²χ(k) plots
|
|
2893
|
+
y_plot = y_plot * (x_plot ** 2)
|
|
2894
|
+
y_full_raw = y_full_raw * (x_full ** 2)
|
|
2895
|
+
raw_y_full_list[-1] = y_full_raw
|
|
2896
|
+
elif getattr(args, 'kchik', False):
|
|
2897
|
+
# Multiply y by x for EXAFS kχ(k) plots
|
|
2898
|
+
y_plot = y_plot * x_plot
|
|
2899
|
+
y_full_raw = y_full_raw * x_full
|
|
2900
|
+
raw_y_full_list[-1] = y_full_raw
|
|
2901
|
+
# elif getattr(args, 'chik', False): no multiplication needed, just label change
|
|
2902
|
+
|
|
2903
|
+
# ---- Normalize (display subset) ----
|
|
2904
|
+
# Auto-normalize for --stack mode, or explicit --norm flag
|
|
2905
|
+
should_normalize = args.stack or getattr(args, 'norm', False)
|
|
2906
|
+
if should_normalize:
|
|
2907
|
+
# Min–max normalization to 0..1 within the currently displayed (cropped) segment
|
|
2908
|
+
if y_plot.size:
|
|
2909
|
+
y_min = float(y_plot.min())
|
|
2910
|
+
y_max = float(y_plot.max())
|
|
2911
|
+
span = y_max - y_min
|
|
2912
|
+
if span > 0:
|
|
2913
|
+
y_norm = (y_plot - y_min) / span
|
|
2914
|
+
else:
|
|
2915
|
+
# Flat line -> all zeros
|
|
2916
|
+
y_norm = np.zeros_like(y_plot)
|
|
2917
|
+
else:
|
|
2918
|
+
y_norm = y_plot
|
|
2919
|
+
else:
|
|
2920
|
+
y_norm = y_plot
|
|
2921
|
+
|
|
2922
|
+
# ---- Apply offset (waterfall vs stack) ----
|
|
2923
|
+
if args.stack:
|
|
2924
|
+
y_plot_offset = y_norm + offset
|
|
2925
|
+
y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
|
|
2926
|
+
gap = y_range + (args.delta * (y_range if args.autoscale else 1.0))
|
|
2927
|
+
offsets_list.append(offset)
|
|
2928
|
+
offset -= gap
|
|
2929
|
+
else:
|
|
2930
|
+
increment = (y_norm.max() - y_norm.min()) * args.delta if (args.autoscale and y_norm.size) else args.delta
|
|
2931
|
+
y_plot_offset = y_norm + offset
|
|
2932
|
+
offsets_list.append(offset)
|
|
2933
|
+
offset += increment
|
|
2934
|
+
|
|
2935
|
+
# ---- Plot curve ----
|
|
2936
|
+
# Swap x and y if --ro flag is set
|
|
2937
|
+
if getattr(args, 'ro', False):
|
|
2938
|
+
ax.plot(y_plot_offset, x_plot, "-", lw=1, alpha=0.8)
|
|
2939
|
+
y_data_list.append(x_plot.copy())
|
|
2940
|
+
x_data_list.append(y_plot_offset)
|
|
2941
|
+
else:
|
|
2942
|
+
|
|
2943
|
+
ax.plot(x_plot, y_plot_offset, "-", lw=1, alpha=0.8)
|
|
2944
|
+
y_data_list.append(y_plot_offset.copy())
|
|
2945
|
+
x_data_list.append(x_plot)
|
|
2946
|
+
labels_list.append(label)
|
|
2947
|
+
# Store current normalized (subset) (used by rearrange logic)
|
|
2948
|
+
orig_y.append(y_norm.copy())
|
|
2949
|
+
|
|
2950
|
+
# ---------------- Force axis to fit all data before labels ----------------
|
|
2951
|
+
ax.relim()
|
|
2952
|
+
ax.autoscale_view()
|
|
2953
|
+
fig.canvas.draw()
|
|
2954
|
+
|
|
2955
|
+
# Store the x/y limits that were used for data normalization (.bpsg save/restore)
|
|
2956
|
+
ax._norm_xlim = tuple(ax.get_xlim())
|
|
2957
|
+
ax._norm_ylim = tuple(ax.get_ylim())
|
|
2958
|
+
|
|
2959
|
+
# Define a sample_tick safely (may be None if no labels yet)
|
|
2960
|
+
sample_tick = None
|
|
2961
|
+
xt_lbls = ax.get_xticklabels()
|
|
2962
|
+
if xt_lbls:
|
|
2963
|
+
sample_tick = xt_lbls[0]
|
|
2964
|
+
|
|
2965
|
+
else:
|
|
2966
|
+
yt_lbls = ax.get_yticklabels()
|
|
2967
|
+
if yt_lbls:
|
|
2968
|
+
sample_tick = yt_lbls[0]
|
|
2969
|
+
|
|
2970
|
+
# ---------------- Initial label creation (REPLACED BLOCK) ----------------
|
|
2971
|
+
# Remove the old simple per-curve placement loop and use:
|
|
2972
|
+
label_text_objects = []
|
|
2973
|
+
tick_fs = sample_tick.get_fontsize() if sample_tick else plt.rcParams.get('font.size', 16)
|
|
2974
|
+
# get_fontname() may not exist on some backends; use family from rcParams if missing
|
|
2975
|
+
try:
|
|
2976
|
+
tick_fn = sample_tick.get_fontname() if sample_tick else plt.rcParams.get('font.sans-serif', ['DejaVu Sans'])[0]
|
|
2977
|
+
except Exception:
|
|
2978
|
+
tick_fn = plt.rcParams.get('font.sans-serif', ['DejaVu Sans'])[0]
|
|
2979
|
+
|
|
2980
|
+
if args.stack:
|
|
2981
|
+
x_max = ax.get_xlim()[1]
|
|
2982
|
+
for i, y_plot_offset in enumerate(y_data_list):
|
|
2983
|
+
y_max_curve = y_plot_offset.max() if len(y_plot_offset) else ax.get_ylim()[1]
|
|
2984
|
+
txt = ax.text(x_max, y_max_curve,
|
|
2985
|
+
f"{i+1}: {labels_list[i]}",
|
|
2986
|
+
va='top', ha='right',
|
|
2987
|
+
fontsize=tick_fs, fontname=tick_fn,
|
|
2988
|
+
transform=ax.transData)
|
|
2989
|
+
label_text_objects.append(txt)
|
|
2990
|
+
else:
|
|
2991
|
+
n = len(y_data_list)
|
|
2992
|
+
top_pad = 0.02
|
|
2993
|
+
start_y = 0.98
|
|
2994
|
+
spacing = min(0.08, max(0.025, 0.90 / max(n, 1)))
|
|
2995
|
+
for i in range(n):
|
|
2996
|
+
y_pos = start_y - i * spacing
|
|
2997
|
+
if y_pos < 0.02:
|
|
2998
|
+
y_pos = 0.02
|
|
2999
|
+
txt = ax.text(1.0, y_pos,
|
|
3000
|
+
f"{i+1}: {labels_list[i]}",
|
|
3001
|
+
va='top', ha='right',
|
|
3002
|
+
fontsize=tick_fs, fontname=tick_fn,
|
|
3003
|
+
transform=ax.transAxes)
|
|
3004
|
+
label_text_objects.append(txt)
|
|
3005
|
+
|
|
3006
|
+
# Ensure consistent initial placement (especially for stacked mode)
|
|
3007
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, False)
|
|
3008
|
+
|
|
3009
|
+
# Initialize curve names visibility (default to visible)
|
|
3010
|
+
fig._curve_names_visible = True
|
|
3011
|
+
# Initialize stack label position (default to top/max)
|
|
3012
|
+
fig._stack_label_at_bottom = False
|
|
3013
|
+
fig._label_anchor_left = False
|
|
3014
|
+
|
|
3015
|
+
# ---------------- CIF tick overlay (after labels placed) ----------------
|
|
3016
|
+
def _ensure_wavelength_for_2theta():
|
|
3017
|
+
"""Ensure wavelength assigned to all CIF tick sets without prompting.
|
|
3018
|
+
|
|
3019
|
+
Order of preference:
|
|
3020
|
+
1. Existing wavelength already stored in any series.
|
|
3021
|
+
2. args.wl if provided by user.
|
|
3022
|
+
3. Previously cached value (cif_cached_wavelength).
|
|
3023
|
+
4. Default 1.5406 Å.
|
|
3024
|
+
"""
|
|
3025
|
+
global cif_cached_wavelength
|
|
3026
|
+
if not cif_tick_series:
|
|
3027
|
+
return None
|
|
3028
|
+
# If any entry already has wavelength, use and cache it
|
|
3029
|
+
for _lab,_fname,_peaks,_wl,_qmax,_color in cif_tick_series:
|
|
3030
|
+
if _wl is not None:
|
|
3031
|
+
cif_cached_wavelength = _wl
|
|
3032
|
+
return _wl
|
|
3033
|
+
wl = getattr(args, 'wl', None)
|
|
3034
|
+
if wl is None:
|
|
3035
|
+
wl = cif_cached_wavelength if cif_cached_wavelength is not None else 1.5406
|
|
3036
|
+
cif_cached_wavelength = wl
|
|
3037
|
+
for i,(lab, fname, peaksQ, w0, qmax_sim, color) in enumerate(cif_tick_series):
|
|
3038
|
+
cif_tick_series[i] = (lab, fname, peaksQ, wl, qmax_sim, color)
|
|
3039
|
+
return wl
|
|
3040
|
+
|
|
3041
|
+
def _Q_to_2theta(peaksQ, wl):
|
|
3042
|
+
out = []
|
|
3043
|
+
if wl is None:
|
|
3044
|
+
return out
|
|
3045
|
+
for q in peaksQ:
|
|
3046
|
+
s = q*wl/(4*np.pi)
|
|
3047
|
+
if 0 <= s < 1:
|
|
3048
|
+
out.append(np.degrees(2*np.arcsin(s)))
|
|
3049
|
+
return out
|
|
3050
|
+
|
|
3051
|
+
def extend_cif_tick_series(xmax_domain):
|
|
3052
|
+
"""Extend CIF peak list if x-range upper bound increases beyond simulated Qmax.
|
|
3053
|
+
xmax_domain: upper x limit in current axis units (Q or 2θ).
|
|
3054
|
+
"""
|
|
3055
|
+
if globals().get('cif_extend_suspended', False):
|
|
3056
|
+
return
|
|
3057
|
+
if not cif_tick_series:
|
|
3058
|
+
return
|
|
3059
|
+
# Determine target Q for extension depending on axis
|
|
3060
|
+
wl_any = None
|
|
3061
|
+
if use_2th:
|
|
3062
|
+
# Ensure wavelength known
|
|
3063
|
+
for _,_,_,wl_,_ in cif_tick_series:
|
|
3064
|
+
if wl_ is not None:
|
|
3065
|
+
wl_any = wl_
|
|
3066
|
+
break
|
|
3067
|
+
if wl_any is None:
|
|
3068
|
+
wl_any = _ensure_wavelength_for_2theta()
|
|
3069
|
+
updated = False
|
|
3070
|
+
for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
|
|
3071
|
+
if use_2th:
|
|
3072
|
+
wl_use = wl if wl is not None else wl_any
|
|
3073
|
+
theta_rad = np.radians(min(xmax_domain, 179.9)/2.0)
|
|
3074
|
+
Q_target = 4*np.pi*np.sin(theta_rad)/wl_use if wl_use else qmax_sim
|
|
3075
|
+
else:
|
|
3076
|
+
Q_target = xmax_domain
|
|
3077
|
+
if not QUIET_CIF_EXTEND:
|
|
3078
|
+
try:
|
|
3079
|
+
print(f"[CIF extend check] {lab}: current Qmax={qmax_sim:.3f}, target Q={Q_target:.3f}")
|
|
3080
|
+
except Exception:
|
|
3081
|
+
pass
|
|
3082
|
+
if Q_target > qmax_sim + 1e-6:
|
|
3083
|
+
new_Qmax = Q_target + 0.25
|
|
3084
|
+
try:
|
|
3085
|
+
# Only apply wavelength constraint for 2θ axis; in Q axis enumerate freely
|
|
3086
|
+
refl = cif_reflection_positions(fname, Qmax=new_Qmax, wavelength=(wl if (wl and use_2th) else None))
|
|
3087
|
+
cif_tick_series[i] = (lab, fname, refl, wl, float(new_Qmax), color)
|
|
3088
|
+
if not QUIET_CIF_EXTEND:
|
|
3089
|
+
print(f"Extended CIF ticks for {lab} to Qmax={new_Qmax:.2f} (count={len(refl)})")
|
|
3090
|
+
updated = True
|
|
3091
|
+
except Exception as e:
|
|
3092
|
+
print(f"Warning: could not extend CIF peaks for {lab}: {e}")
|
|
3093
|
+
if updated:
|
|
3094
|
+
# After update, redraw ticks
|
|
3095
|
+
draw_cif_ticks()
|
|
3096
|
+
|
|
3097
|
+
def draw_cif_ticks():
|
|
3098
|
+
if not cif_tick_series:
|
|
3099
|
+
return
|
|
3100
|
+
# Preserve both x and y-axis limits to prevent movement when toggling
|
|
3101
|
+
prev_xlim = ax.get_xlim()
|
|
3102
|
+
prev_ylim = ax.get_ylim()
|
|
3103
|
+
# Use preserved y-axis limits for calculations to prevent incremental movement
|
|
3104
|
+
orig_ylim = prev_ylim
|
|
3105
|
+
orig_yr = orig_ylim[1] - orig_ylim[0]
|
|
3106
|
+
if orig_yr <= 0: orig_yr = 1.0
|
|
3107
|
+
# Check visibility flag first to decide if we need to adjust y-axis
|
|
3108
|
+
show_titles = show_cif_titles # Use closure variable
|
|
3109
|
+
try:
|
|
3110
|
+
# Check __main__ module first (for backward compatibility)
|
|
3111
|
+
_bp_module = sys.modules.get('__main__')
|
|
3112
|
+
if _bp_module is not None and hasattr(_bp_module, 'show_cif_titles'):
|
|
3113
|
+
show_titles = bool(getattr(_bp_module, 'show_cif_titles', True))
|
|
3114
|
+
# Also check if stored on figure/axes (from interactive menu)
|
|
3115
|
+
if hasattr(fig, '_bp_show_cif_titles'):
|
|
3116
|
+
show_titles = bool(getattr(fig, '_bp_show_cif_titles', True))
|
|
3117
|
+
except Exception:
|
|
3118
|
+
pass
|
|
3119
|
+
# Calculate base and spacing based on original y-axis limits
|
|
3120
|
+
if args.stack or len(y_data_list) > 1:
|
|
3121
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
|
|
3122
|
+
base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
|
|
3123
|
+
else:
|
|
3124
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
|
|
3125
|
+
base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
|
|
3126
|
+
# Only adjust y-axis limits if titles are visible
|
|
3127
|
+
needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
|
|
3128
|
+
cur_ylim = ax.get_ylim()
|
|
3129
|
+
yr = cur_ylim[1] - cur_ylim[0]
|
|
3130
|
+
if yr <= 0: yr = 1.0
|
|
3131
|
+
if show_titles and needed_min < orig_ylim[0]:
|
|
3132
|
+
# Expand y-axis only if needed, using original limits as reference
|
|
3133
|
+
ax.set_ylim(needed_min, orig_ylim[1])
|
|
3134
|
+
cur_ylim = ax.get_ylim()
|
|
3135
|
+
yr = cur_ylim[1] - cur_ylim[0]
|
|
3136
|
+
if yr <= 0: yr = 1.0
|
|
3137
|
+
# Recalculate base with new limits if we expanded
|
|
3138
|
+
if args.stack or len(y_data_list) > 1:
|
|
3139
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
|
|
3140
|
+
base = global_min - 0.08*yr; spacing = 0.05*yr
|
|
3141
|
+
else:
|
|
3142
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
|
|
3143
|
+
base = global_min - 0.06*yr; spacing = 0.04*yr
|
|
3144
|
+
# Clear previous
|
|
3145
|
+
for art in getattr(ax, '_cif_tick_art', []):
|
|
3146
|
+
try: art.remove()
|
|
3147
|
+
except Exception: pass
|
|
3148
|
+
new_art = []
|
|
3149
|
+
mixed_mode = (not cif_only) # cif_only variable defined earlier in script context
|
|
3150
|
+
show_hkl = globals().get('show_cif_hkl', False)
|
|
3151
|
+
for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
|
|
3152
|
+
y_line = base - i*spacing
|
|
3153
|
+
if use_2th:
|
|
3154
|
+
if wl is None: wl = _ensure_wavelength_for_2theta()
|
|
3155
|
+
domain_peaks = _Q_to_2theta(peaksQ, wl)
|
|
3156
|
+
else:
|
|
3157
|
+
domain_peaks = peaksQ
|
|
3158
|
+
# --- NEW: restrict to current visible x-range for performance ---
|
|
3159
|
+
xlow, xhigh = ax.get_xlim()
|
|
3160
|
+
if domain_peaks:
|
|
3161
|
+
# domain_peaks may be numpy array or list; create filtered list
|
|
3162
|
+
domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
|
|
3163
|
+
if not domain_peaks:
|
|
3164
|
+
# No peaks in current window; still write label row and continue (if titles are visible)
|
|
3165
|
+
if show_titles:
|
|
3166
|
+
# Removed numbering; keep space padding
|
|
3167
|
+
label_text = f" {lab}"
|
|
3168
|
+
txt = ax.text(prev_xlim[0], y_line + 0.005*yr, label_text,
|
|
3169
|
+
ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
|
|
3170
|
+
new_art.append(txt)
|
|
3171
|
+
continue
|
|
3172
|
+
# Build map for quick hkl lookup by Q
|
|
3173
|
+
hkl_entries = cif_hkl_map.get(fname, [])
|
|
3174
|
+
# dictionary keyed by Q value
|
|
3175
|
+
hkl_by_q = {}
|
|
3176
|
+
for qval,h,k,l in hkl_entries:
|
|
3177
|
+
hkl_by_q.setdefault(qval, []).append((h,k,l))
|
|
3178
|
+
label_map = cif_hkl_label_map.get(fname, {})
|
|
3179
|
+
# --- Optimized tick & hkl label drawing ---
|
|
3180
|
+
if show_hkl and peaksQ and label_map:
|
|
3181
|
+
# Guard against pathological large peak lists (can freeze UI)
|
|
3182
|
+
if len(peaksQ) > 4000 or len(domain_peaks) > 4000:
|
|
3183
|
+
print(f"[hkl] Too many peaks in {lab} (>{len(peaksQ)}) – skipping hkl labels. Press 'z' again to toggle off.")
|
|
3184
|
+
# still draw ticks below without labels
|
|
3185
|
+
effective_show_hkl = False
|
|
3186
|
+
else:
|
|
3187
|
+
effective_show_hkl = True
|
|
3188
|
+
else:
|
|
3189
|
+
effective_show_hkl = False
|
|
3190
|
+
|
|
3191
|
+
# Precompute rounding function once
|
|
3192
|
+
if effective_show_hkl:
|
|
3193
|
+
# For 2θ axis we convert back to Q then round; otherwise Q directly
|
|
3194
|
+
for p in domain_peaks:
|
|
3195
|
+
ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
|
|
3196
|
+
new_art.append(ln)
|
|
3197
|
+
if use_2th and wl:
|
|
3198
|
+
theta = np.radians(p/2.0)
|
|
3199
|
+
Qp = 4*np.pi*np.sin(theta)/wl
|
|
3200
|
+
else:
|
|
3201
|
+
Qp = p
|
|
3202
|
+
lbl = label_map.get(round(Qp,6))
|
|
3203
|
+
if lbl:
|
|
3204
|
+
t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
|
|
3205
|
+
new_art.append(t_hkl)
|
|
3206
|
+
else:
|
|
3207
|
+
# Just draw ticks (no hkl labels)
|
|
3208
|
+
for p in domain_peaks:
|
|
3209
|
+
ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
|
|
3210
|
+
new_art.append(ln)
|
|
3211
|
+
# Removed numbering; keep space padding (placed per CIF row)
|
|
3212
|
+
# Only add title label if show_cif_titles is True
|
|
3213
|
+
if show_titles:
|
|
3214
|
+
label_text = f" {lab}"
|
|
3215
|
+
txt = ax.text(prev_xlim[0], y_line + 0.005*yr, label_text,
|
|
3216
|
+
ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
|
|
3217
|
+
new_art.append(txt)
|
|
3218
|
+
ax._cif_tick_art = new_art
|
|
3219
|
+
# Restore both x and y-axis limits to prevent movement
|
|
3220
|
+
ax.set_xlim(prev_xlim)
|
|
3221
|
+
# Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
|
|
3222
|
+
if not show_titles:
|
|
3223
|
+
# Titles hidden: always restore original limits
|
|
3224
|
+
ax.set_ylim(prev_ylim)
|
|
3225
|
+
elif needed_min >= prev_ylim[0]:
|
|
3226
|
+
# Titles shown but no expansion needed: restore original limits
|
|
3227
|
+
ax.set_ylim(prev_ylim)
|
|
3228
|
+
# Otherwise, keep the expanded limits (already set above)
|
|
3229
|
+
# Store simplified metadata for hover: list of dicts with 'x','y','label'
|
|
3230
|
+
hover_meta = []
|
|
3231
|
+
show_hkl = globals().get('show_cif_hkl', False)
|
|
3232
|
+
# Build mapping from Q to label text if available
|
|
3233
|
+
for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
|
|
3234
|
+
if use_2th and wl is None:
|
|
3235
|
+
wl = getattr(ax, '_cif_hover_wl', None)
|
|
3236
|
+
# Recreate domain peaks consistent with those drawn (limit to view)
|
|
3237
|
+
if use_2th:
|
|
3238
|
+
if wl is None: continue
|
|
3239
|
+
domain_peaks = _Q_to_2theta(peaksQ, wl)
|
|
3240
|
+
else:
|
|
3241
|
+
domain_peaks = peaksQ
|
|
3242
|
+
xlow, xhigh = ax.get_xlim()
|
|
3243
|
+
domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
|
|
3244
|
+
if not domain_peaks:
|
|
3245
|
+
continue
|
|
3246
|
+
# y baseline for this series (same logic as above)
|
|
3247
|
+
if args.stack or len(y_data_list) > 1:
|
|
3248
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else ax.get_ylim()[0]
|
|
3249
|
+
base = global_min - 0.08*yr; spacing = 0.05*yr
|
|
3250
|
+
else:
|
|
3251
|
+
global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
|
|
3252
|
+
base = global_min - 0.06*yr; spacing = 0.04*yr
|
|
3253
|
+
y_line = base - i*spacing
|
|
3254
|
+
label_map = cif_hkl_label_map.get(fname, {}) if show_hkl else {}
|
|
3255
|
+
for p in domain_peaks:
|
|
3256
|
+
if use_2th and wl:
|
|
3257
|
+
theta = np.radians(p/2.0); Qp = 4*np.pi*np.sin(theta)/wl
|
|
3258
|
+
else:
|
|
3259
|
+
Qp = p
|
|
3260
|
+
lbl = label_map.get(round(Qp,6), None)
|
|
3261
|
+
hover_meta.append({'x': p, 'y': y_line, 'hkl': lbl, 'series': lab})
|
|
3262
|
+
ax._cif_tick_hover_meta = hover_meta
|
|
3263
|
+
fig.canvas.draw_idle()
|
|
3264
|
+
|
|
3265
|
+
# Install hover handler once
|
|
3266
|
+
if not hasattr(ax, '_cif_hover_cid'):
|
|
3267
|
+
tooltip = ax.text(0,0,"", va='bottom', ha='left', fontsize=8,
|
|
3268
|
+
color='black', bbox=dict(boxstyle='round,pad=0.2', fc='1.0', ec='0.7', alpha=0.85),
|
|
3269
|
+
visible=False)
|
|
3270
|
+
ax._cif_hover_tooltip = tooltip
|
|
3271
|
+
def _on_move(event):
|
|
3272
|
+
if event.inaxes != ax:
|
|
3273
|
+
if tooltip.get_visible():
|
|
3274
|
+
tooltip.set_visible(False); fig.canvas.draw_idle()
|
|
3275
|
+
return
|
|
3276
|
+
meta = getattr(ax, '_cif_tick_hover_meta', None)
|
|
3277
|
+
if not meta:
|
|
3278
|
+
if tooltip.get_visible():
|
|
3279
|
+
tooltip.set_visible(False); fig.canvas.draw_idle()
|
|
3280
|
+
return
|
|
3281
|
+
x = event.xdata; y = event.ydata
|
|
3282
|
+
# Find nearest tick within pixel tolerance
|
|
3283
|
+
trans = ax.transData
|
|
3284
|
+
best = None; best_d2 = 25 # squared pixel distance threshold (5 px)
|
|
3285
|
+
for entry in meta:
|
|
3286
|
+
px, py = trans.transform((entry['x'], entry['y']))
|
|
3287
|
+
ex, ey = trans.transform((x, y))
|
|
3288
|
+
d2 = (px-ex)**2 + (py-ey)**2
|
|
3289
|
+
if d2 < best_d2:
|
|
3290
|
+
best_d2 = d2; best = entry
|
|
3291
|
+
if best is None:
|
|
3292
|
+
if tooltip.get_visible():
|
|
3293
|
+
tooltip.set_visible(False); fig.canvas.draw_idle()
|
|
3294
|
+
return
|
|
3295
|
+
# Compose text
|
|
3296
|
+
hkl_txt = best['hkl'] if best.get('hkl') else ''
|
|
3297
|
+
tip = f"{best['series']}\nQ={best['x']:.4f}" if use_Q else (f"{best['series']}\n2θ={best['x']:.4f}" if use_2th else f"{best['series']} {best['x']:.4f}")
|
|
3298
|
+
if hkl_txt:
|
|
3299
|
+
tip += f"\n{hkl_txt}"
|
|
3300
|
+
tooltip.set_text(tip)
|
|
3301
|
+
tooltip.set_position((best['x'], best['y'] + 0.025*yr))
|
|
3302
|
+
if not tooltip.get_visible():
|
|
3303
|
+
tooltip.set_visible(True)
|
|
3304
|
+
fig.canvas.draw_idle()
|
|
3305
|
+
cid = fig.canvas.mpl_connect('motion_notify_event', _on_move)
|
|
3306
|
+
ax._cif_hover_cid = cid
|
|
3307
|
+
|
|
3308
|
+
if cif_tick_series:
|
|
3309
|
+
# Auto-assign distinct colors if all are default 'k'
|
|
3310
|
+
if len(cif_tick_series) > 1:
|
|
3311
|
+
if all(c[-1] == 'k' for c in cif_tick_series):
|
|
3312
|
+
try:
|
|
3313
|
+
cmap_name = 'tab10' if len(cif_tick_series) <= 10 else 'hsv'
|
|
3314
|
+
cmap = plt.get_cmap(cmap_name)
|
|
3315
|
+
new_series = []
|
|
3316
|
+
for i,(lab,fname,peaksQ,wl,qmax_sim,col) in enumerate(cif_tick_series):
|
|
3317
|
+
color = cmap(i / max(1,(len(cif_tick_series)-1)))
|
|
3318
|
+
new_series.append((lab,fname,peaksQ,wl,qmax_sim,color))
|
|
3319
|
+
cif_tick_series[:] = new_series
|
|
3320
|
+
except Exception:
|
|
3321
|
+
pass
|
|
3322
|
+
if use_2th:
|
|
3323
|
+
_ensure_wavelength_for_2theta()
|
|
3324
|
+
draw_cif_ticks()
|
|
3325
|
+
# expose helpers for interactive updates
|
|
3326
|
+
ax._cif_extend_func = extend_cif_tick_series
|
|
3327
|
+
ax._cif_draw_func = draw_cif_ticks
|
|
3328
|
+
|
|
3329
|
+
# Handle EXAFS k-weighted χ(k) mode labels
|
|
3330
|
+
if getattr(args, 'k3chik', False):
|
|
3331
|
+
x_label = r"k ($\mathrm{\AA}^{-1}$)"
|
|
3332
|
+
y_label = r"k$^3$χ(k) ($\mathrm{\AA}^{-3}$)"
|
|
3333
|
+
elif getattr(args, 'k2chik', False):
|
|
3334
|
+
x_label = r"k ($\mathrm{\AA}^{-1}$)"
|
|
3335
|
+
y_label = r"k$^2$χ(k) ($\mathrm{\AA}^{-2}$)"
|
|
3336
|
+
elif getattr(args, 'kchik', False):
|
|
3337
|
+
x_label = r"k ($\mathrm{\AA}^{-1}$)"
|
|
3338
|
+
y_label = r"kχ(k) ($\mathrm{\AA}^{-1}$)"
|
|
3339
|
+
elif getattr(args, 'chik', False):
|
|
3340
|
+
x_label = r"k ($\mathrm{\AA}^{-1}$)"
|
|
3341
|
+
y_label = r"χ(k)"
|
|
3342
|
+
else:
|
|
3343
|
+
if use_E: x_label = "Energy (eV)"
|
|
3344
|
+
elif use_r: x_label = r"r (Å)"
|
|
3345
|
+
elif use_k: x_label = r"k ($\mathrm{\AA}^{-1}$)"
|
|
3346
|
+
elif use_rft: x_label = "Radial distance (Å)"
|
|
3347
|
+
elif use_Q: x_label = r"Q ($\mathrm{\AA}^{-1}$)"
|
|
3348
|
+
elif use_2th: x_label = r"$2\theta$ (deg)"
|
|
3349
|
+
elif use_time: x_label = "Time (h)"
|
|
3350
|
+
elif args.xaxis:
|
|
3351
|
+
x_label = str(args.xaxis)
|
|
3352
|
+
else:
|
|
3353
|
+
x_label = "X"
|
|
3354
|
+
|
|
3355
|
+
# Y-axis label: normalized if --stack or --norm, or voltage for time mode
|
|
3356
|
+
should_normalize = args.stack or getattr(args, 'norm', False)
|
|
3357
|
+
if use_time:
|
|
3358
|
+
y_label = "Voltage (V)"
|
|
3359
|
+
elif should_normalize:
|
|
3360
|
+
y_label = "Normalized intensity (a.u.)"
|
|
3361
|
+
else:
|
|
3362
|
+
y_label = "Intensity"
|
|
3363
|
+
|
|
3364
|
+
# Swap axis labels if --ro flag is set
|
|
3365
|
+
if getattr(args, 'ro', False):
|
|
3366
|
+
ax.set_xlabel(y_label, fontsize=16)
|
|
3367
|
+
ax.set_ylabel(x_label, fontsize=16)
|
|
3368
|
+
else:
|
|
3369
|
+
|
|
3370
|
+
ax.set_xlabel(x_label, fontsize=16)
|
|
3371
|
+
ax.set_ylabel(y_label, fontsize=16)
|
|
3372
|
+
|
|
3373
|
+
# Store originals for axis-title toggle restoration (t menu bn/ln)
|
|
3374
|
+
try:
|
|
3375
|
+
ax._stored_xlabel = ax.get_xlabel()
|
|
3376
|
+
ax._stored_ylabel = ax.get_ylabel()
|
|
3377
|
+
except Exception:
|
|
3378
|
+
pass
|
|
3379
|
+
|
|
3380
|
+
# --- FINAL LABEL POSITION PASS ---
|
|
3381
|
+
# Some downstream operations (e.g. CIF tick overlay extending y-limits or auto margin
|
|
3382
|
+
# adjustments by certain backends) can occur after the initial label placement,
|
|
3383
|
+
# leading to visibly misplaced curve labels on first show. We perform a final
|
|
3384
|
+
# synchronous draw + update_labels here to lock them to the correct coordinates
|
|
3385
|
+
# before any saving / interactive session starts. (Subsequent interactions still
|
|
3386
|
+
# use the existing callbacks / update logic.)
|
|
3387
|
+
try:
|
|
3388
|
+
fig.canvas.draw() # ensure limits are finalized
|
|
3389
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, False)
|
|
3390
|
+
except Exception:
|
|
3391
|
+
pass
|
|
3392
|
+
|
|
3393
|
+
# ---------------- Apply style file if provided ----------------
|
|
3394
|
+
if style_cfg:
|
|
3395
|
+
try:
|
|
3396
|
+
from .batch import _apply_xy_style
|
|
3397
|
+
_apply_xy_style(fig, ax, style_cfg)
|
|
3398
|
+
# Redraw after applying style
|
|
3399
|
+
fig.canvas.draw()
|
|
3400
|
+
except Exception as e:
|
|
3401
|
+
print(f"Warning: Error applying style file: {e}")
|
|
3402
|
+
|
|
3403
|
+
# ---------------- Save figure object ----------------
|
|
3404
|
+
if args.savefig:
|
|
3405
|
+
# Remove numbering for exported figure object (if ticks present)
|
|
3406
|
+
if cif_tick_series and 'cif_numbering_enabled' in globals() and cif_numbering_enabled:
|
|
3407
|
+
prev_num = cif_numbering_enabled
|
|
3408
|
+
cif_numbering_enabled = False
|
|
3409
|
+
if 'draw_cif_ticks' in globals():
|
|
3410
|
+
draw_cif_ticks()
|
|
3411
|
+
target = _confirm_overwrite(args.savefig)
|
|
3412
|
+
if target:
|
|
3413
|
+
with open(target, "wb") as f:
|
|
3414
|
+
pickle.dump(fig, f)
|
|
3415
|
+
cif_numbering_enabled = prev_num
|
|
3416
|
+
if 'draw_cif_ticks' in globals():
|
|
3417
|
+
draw_cif_ticks()
|
|
3418
|
+
else:
|
|
3419
|
+
target = _confirm_overwrite(args.savefig)
|
|
3420
|
+
if target:
|
|
3421
|
+
with open(target, "wb") as f:
|
|
3422
|
+
pickle.dump(fig, f)
|
|
3423
|
+
if target:
|
|
3424
|
+
print(f"Saved figure object to {target}")
|
|
3425
|
+
|
|
3426
|
+
# ---------------- Show and interactive menu ----------------
|
|
3427
|
+
if args.interactive:
|
|
3428
|
+
# Show the current figure once (non-blocking) so interactive menu updates reuse this window
|
|
3429
|
+
try:
|
|
3430
|
+
plt.ion()
|
|
3431
|
+
except Exception:
|
|
3432
|
+
pass
|
|
3433
|
+
try:
|
|
3434
|
+
# Using canvas draw without show first avoids new-window creation on some backends
|
|
3435
|
+
fig.canvas.draw_idle(); fig.canvas.flush_events()
|
|
3436
|
+
except Exception:
|
|
3437
|
+
pass
|
|
3438
|
+
try:
|
|
3439
|
+
plt.show(block=False)
|
|
3440
|
+
except Exception:
|
|
3441
|
+
pass
|
|
3442
|
+
# Increase default upper margin (more space): reduce 'top' value once and lock
|
|
3443
|
+
try:
|
|
3444
|
+
sp = fig.subplotpars
|
|
3445
|
+
if sp.top >= 0.88: # only if near default
|
|
3446
|
+
fig.subplots_adjust(top=0.88)
|
|
3447
|
+
fig._interactive_top_locked = True
|
|
3448
|
+
except Exception:
|
|
3449
|
+
pass
|
|
3450
|
+
|
|
3451
|
+
# CRITICAL: Disable automatic layout adjustments to ensure parameter independence
|
|
3452
|
+
# This prevents matplotlib from moving axes when labels are changed
|
|
3453
|
+
try:
|
|
3454
|
+
fig.set_layout_engine('none')
|
|
3455
|
+
except AttributeError:
|
|
3456
|
+
# Older matplotlib versions - disable tight_layout
|
|
3457
|
+
try:
|
|
3458
|
+
fig.set_tight_layout(False)
|
|
3459
|
+
except Exception:
|
|
3460
|
+
pass
|
|
3461
|
+
|
|
3462
|
+
# Build CIF globals dict for explicit passing
|
|
3463
|
+
cif_globals = {
|
|
3464
|
+
'cif_tick_series': cif_tick_series,
|
|
3465
|
+
'cif_hkl_map': cif_hkl_map,
|
|
3466
|
+
'cif_hkl_label_map': cif_hkl_label_map,
|
|
3467
|
+
'show_cif_hkl': show_cif_hkl,
|
|
3468
|
+
'show_cif_titles': show_cif_titles,
|
|
3469
|
+
'cif_extend_suspended': cif_extend_suspended,
|
|
3470
|
+
'keep_canvas_fixed': keep_canvas_fixed,
|
|
3471
|
+
'file_wavelength_info': file_wavelength_info,
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
interactive_menu(
|
|
3475
|
+
fig, ax, y_data_list, x_data_list, labels_list,
|
|
3476
|
+
orig_y, label_text_objects, args.delta, x_label, args,
|
|
3477
|
+
x_full_list, raw_y_full_list, offsets_list,
|
|
3478
|
+
use_Q, use_r, use_E, use_k, use_rft,
|
|
3479
|
+
cif_globals=cif_globals,
|
|
3480
|
+
)
|
|
3481
|
+
elif args.out:
|
|
3482
|
+
out_file = args.out
|
|
3483
|
+
if not os.path.splitext(out_file)[1]:
|
|
3484
|
+
out_file += ".svg"
|
|
3485
|
+
# Confirm overwrite for export path
|
|
3486
|
+
export_target = _confirm_overwrite(out_file)
|
|
3487
|
+
if not export_target:
|
|
3488
|
+
print("Export canceled.")
|
|
3489
|
+
else:
|
|
3490
|
+
for i, txt in enumerate(label_text_objects):
|
|
3491
|
+
txt.set_text(labels_list[i])
|
|
3492
|
+
# Temporarily disable numbering for export
|
|
3493
|
+
if cif_tick_series and 'cif_numbering_enabled' in globals() and cif_numbering_enabled:
|
|
3494
|
+
prev_num = cif_numbering_enabled
|
|
3495
|
+
cif_numbering_enabled = False
|
|
3496
|
+
if 'draw_cif_ticks' in globals():
|
|
3497
|
+
draw_cif_ticks()
|
|
3498
|
+
# Transparent background for SVG exports
|
|
3499
|
+
_, _ext = os.path.splitext(export_target)
|
|
3500
|
+
if _ext.lower() == '.svg':
|
|
3501
|
+
try:
|
|
3502
|
+
_fig_fc = fig.get_facecolor()
|
|
3503
|
+
except Exception:
|
|
3504
|
+
_fig_fc = None
|
|
3505
|
+
try:
|
|
3506
|
+
_ax_fc = ax.get_facecolor()
|
|
3507
|
+
except Exception:
|
|
3508
|
+
_ax_fc = None
|
|
3509
|
+
try:
|
|
3510
|
+
if getattr(fig, 'patch', None) is not None:
|
|
3511
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
3512
|
+
if getattr(ax, 'patch', None) is not None:
|
|
3513
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
3514
|
+
except Exception:
|
|
3515
|
+
pass
|
|
3516
|
+
try:
|
|
3517
|
+
fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
3518
|
+
finally:
|
|
3519
|
+
try:
|
|
3520
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
3521
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
3522
|
+
except Exception:
|
|
3523
|
+
pass
|
|
3524
|
+
try:
|
|
3525
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
3526
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
3527
|
+
except Exception:
|
|
3528
|
+
pass
|
|
3529
|
+
else:
|
|
3530
|
+
fig.savefig(export_target, dpi=300)
|
|
3531
|
+
cif_numbering_enabled = prev_num
|
|
3532
|
+
if 'draw_cif_ticks' in globals():
|
|
3533
|
+
draw_cif_ticks()
|
|
3534
|
+
else:
|
|
3535
|
+
# Transparent background for SVG exports
|
|
3536
|
+
_, _ext = os.path.splitext(export_target)
|
|
3537
|
+
if _ext.lower() == '.svg':
|
|
3538
|
+
try:
|
|
3539
|
+
_fig_fc = fig.get_facecolor()
|
|
3540
|
+
except Exception:
|
|
3541
|
+
_fig_fc = None
|
|
3542
|
+
try:
|
|
3543
|
+
_ax_fc = ax.get_facecolor()
|
|
3544
|
+
except Exception:
|
|
3545
|
+
_ax_fc = None
|
|
3546
|
+
try:
|
|
3547
|
+
if getattr(fig, 'patch', None) is not None:
|
|
3548
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
3549
|
+
if getattr(ax, 'patch', None) is not None:
|
|
3550
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
3551
|
+
except Exception:
|
|
3552
|
+
pass
|
|
3553
|
+
try:
|
|
3554
|
+
fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
3555
|
+
finally:
|
|
3556
|
+
try:
|
|
3557
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
3558
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
3559
|
+
except Exception:
|
|
3560
|
+
pass
|
|
3561
|
+
try:
|
|
3562
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
3563
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
3564
|
+
except Exception:
|
|
3565
|
+
pass
|
|
3566
|
+
else:
|
|
3567
|
+
fig.savefig(export_target, dpi=300)
|
|
3568
|
+
print(f"Saved plot to {export_target}")
|
|
3569
|
+
else:
|
|
3570
|
+
# Default: show the plot in non-interactive, non-save mode
|
|
3571
|
+
try:
|
|
3572
|
+
_backend = plt.get_backend()
|
|
3573
|
+
except Exception:
|
|
3574
|
+
_backend = "unknown"
|
|
3575
|
+
# TkAgg, QtAgg, Qt5Agg, WXAgg, MacOSX etc. are interactive
|
|
3576
|
+
_interactive_backends = {"tkagg", "qt5agg", "qt4agg", "qtagg", "wxagg", "macosx", "gtk3agg", "gtk4agg", "wx", "qt", "gtk", "gtk3", "gtk4"}
|
|
3577
|
+
_is_noninteractive = isinstance(_backend, str) and (_backend.lower() not in _interactive_backends) and ("agg" in _backend.lower() or _backend.lower() in {"pdf","ps","svg","template"})
|
|
3578
|
+
if not _is_noninteractive:
|
|
3579
|
+
plt.show()
|
|
3580
|
+
else:
|
|
3581
|
+
print(f"Matplotlib backend '{_backend}' is non-interactive; use --out to save the figure.")
|
|
3582
|
+
|
|
3583
|
+
# Success
|
|
3584
|
+
return 0
|
|
3585
|
+
|
|
3586
|
+
|
|
3587
|
+
# Entry point for CLI
|
|
3588
|
+
if __name__ == "__main__":
|
|
3589
|
+
sys.exit(batplot_main())
|