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,4520 @@
|
|
|
1
|
+
"""Interactive menu for electrochemistry (.mpt GC) plots.
|
|
2
|
+
|
|
3
|
+
Provides a minimal interactive loop when running:
|
|
4
|
+
batplot file.mpt --gc --mass <mg> --interactive
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import matplotlib.pyplot as plt
|
|
15
|
+
import matplotlib.cm as cm
|
|
16
|
+
import numpy as np
|
|
17
|
+
from matplotlib import colors as mcolors
|
|
18
|
+
from .ui import (
|
|
19
|
+
resize_plot_frame, resize_canvas,
|
|
20
|
+
update_tick_visibility as _ui_update_tick_visibility,
|
|
21
|
+
position_top_xlabel as _ui_position_top_xlabel,
|
|
22
|
+
position_right_ylabel as _ui_position_right_ylabel,
|
|
23
|
+
position_bottom_xlabel as _ui_position_bottom_xlabel,
|
|
24
|
+
position_left_ylabel as _ui_position_left_ylabel,
|
|
25
|
+
)
|
|
26
|
+
from matplotlib.ticker import MaxNLocator, AutoMinorLocator, NullFormatter, NullLocator
|
|
27
|
+
from .plotting import update_labels as _update_labels
|
|
28
|
+
from .utils import (
|
|
29
|
+
_confirm_overwrite,
|
|
30
|
+
choose_save_path,
|
|
31
|
+
choose_style_file,
|
|
32
|
+
list_files_in_subdirectory,
|
|
33
|
+
get_organized_path,
|
|
34
|
+
convert_label_shortcuts,
|
|
35
|
+
)
|
|
36
|
+
import time
|
|
37
|
+
from .color_utils import (
|
|
38
|
+
color_block,
|
|
39
|
+
color_bar,
|
|
40
|
+
palette_preview,
|
|
41
|
+
manage_user_colors,
|
|
42
|
+
get_user_color_list,
|
|
43
|
+
resolve_color_token,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _FilterIMKWarning:
|
|
48
|
+
"""Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
|
|
49
|
+
def __init__(self, original_stderr):
|
|
50
|
+
self.original_stderr = original_stderr
|
|
51
|
+
|
|
52
|
+
def write(self, message):
|
|
53
|
+
# Filter out the harmless macOS IMK warning
|
|
54
|
+
if 'IMKCFRunLoopWakeUpReliable' not in message:
|
|
55
|
+
self.original_stderr.write(message)
|
|
56
|
+
|
|
57
|
+
def flush(self):
|
|
58
|
+
self.original_stderr.flush()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_input(prompt: str = "") -> str:
|
|
62
|
+
"""Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
|
|
63
|
+
|
|
64
|
+
This is a harmless macOS system message that appears when using input() in terminals.
|
|
65
|
+
"""
|
|
66
|
+
# Filter stderr to hide macOS IMK warnings while preserving other errors
|
|
67
|
+
original_stderr = sys.stderr
|
|
68
|
+
sys.stderr = _FilterIMKWarning(original_stderr)
|
|
69
|
+
try:
|
|
70
|
+
result = input(prompt)
|
|
71
|
+
return result
|
|
72
|
+
except (KeyboardInterrupt, EOFError):
|
|
73
|
+
raise
|
|
74
|
+
finally:
|
|
75
|
+
sys.stderr = original_stderr
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _colorize_menu(text):
|
|
79
|
+
"""Colorize menu items: command in cyan, colon in white, description in default."""
|
|
80
|
+
if ':' not in text:
|
|
81
|
+
return text
|
|
82
|
+
parts = text.split(':', 1)
|
|
83
|
+
cmd = parts[0].strip()
|
|
84
|
+
desc = parts[1].strip() if len(parts) > 1 else ''
|
|
85
|
+
return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _format_file_timestamp(filepath: str) -> str:
|
|
89
|
+
"""Format file modification time for display.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
filepath: Full path to the file
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Formatted timestamp string (e.g., "2024-01-15 14:30") or empty string if error
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
mtime = os.path.getmtime(filepath)
|
|
99
|
+
# Format as YYYY-MM-DD HH:MM
|
|
100
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
|
|
101
|
+
except Exception:
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _colorize_prompt(text):
|
|
106
|
+
"""Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n)."""
|
|
107
|
+
import re
|
|
108
|
+
pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
|
|
109
|
+
|
|
110
|
+
def colorize_match(match):
|
|
111
|
+
content = match.group(1)
|
|
112
|
+
if '/' in content:
|
|
113
|
+
parts = content.split('/')
|
|
114
|
+
colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
|
|
115
|
+
return f"({'/'.join(colored_parts)})"
|
|
116
|
+
else:
|
|
117
|
+
parts = content.split(',')
|
|
118
|
+
colored_parts = []
|
|
119
|
+
for part in parts:
|
|
120
|
+
part = part.strip()
|
|
121
|
+
if '=' in part:
|
|
122
|
+
cmd, desc = part.split('=', 1)
|
|
123
|
+
colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
|
|
124
|
+
else:
|
|
125
|
+
colored_parts.append(part)
|
|
126
|
+
return f"({', '.join(colored_parts)})"
|
|
127
|
+
|
|
128
|
+
return re.sub(pattern, colorize_match, text)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _colorize_inline_commands(text):
|
|
132
|
+
"""Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
|
|
133
|
+
import re
|
|
134
|
+
# Color quoted command examples (like 's2 w5 a4', 'w2 w5')
|
|
135
|
+
text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
|
|
136
|
+
# Color specific known commands: q, i, l, list, help, all
|
|
137
|
+
text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
|
|
138
|
+
return text
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _apply_stored_axis_colors(ax):
|
|
142
|
+
try:
|
|
143
|
+
color = getattr(ax, '_stored_xlabel_color', None)
|
|
144
|
+
if color:
|
|
145
|
+
ax.xaxis.label.set_color(color)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
try:
|
|
149
|
+
color = getattr(ax, '_stored_ylabel_color', None)
|
|
150
|
+
if color:
|
|
151
|
+
ax.yaxis.label.set_color(color)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
try:
|
|
155
|
+
top_artist = getattr(ax, '_top_xlabel_artist', None)
|
|
156
|
+
color = getattr(ax, '_stored_top_xlabel_color', None)
|
|
157
|
+
if top_artist is not None and color:
|
|
158
|
+
top_artist.set_color(color)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
try:
|
|
162
|
+
right_artist = getattr(ax, '_right_ylabel_artist', None)
|
|
163
|
+
color = getattr(ax, '_stored_right_ylabel_color', None)
|
|
164
|
+
if right_artist is not None and color:
|
|
165
|
+
right_artist.set_color(color)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply_spine_color(ax, fig, tick_state, spine_name: str, color) -> None:
|
|
171
|
+
if color is None:
|
|
172
|
+
return
|
|
173
|
+
sp = ax.spines.get(spine_name)
|
|
174
|
+
if sp is not None:
|
|
175
|
+
try:
|
|
176
|
+
sp.set_edgecolor(color)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
try:
|
|
180
|
+
if spine_name in ('top', 'bottom'):
|
|
181
|
+
ax.tick_params(axis='x', which='both', colors=color)
|
|
182
|
+
ax.xaxis.label.set_color(color)
|
|
183
|
+
ax._stored_xlabel_color = color
|
|
184
|
+
if spine_name == 'top':
|
|
185
|
+
ax._stored_top_xlabel_color = color
|
|
186
|
+
artist = getattr(ax, '_top_xlabel_artist', None)
|
|
187
|
+
if artist is not None:
|
|
188
|
+
artist.set_color(color)
|
|
189
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
190
|
+
else:
|
|
191
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
192
|
+
else:
|
|
193
|
+
ax.tick_params(axis='y', which='both', colors=color)
|
|
194
|
+
ax.yaxis.label.set_color(color)
|
|
195
|
+
ax._stored_ylabel_color = color
|
|
196
|
+
if spine_name == 'right':
|
|
197
|
+
ax._stored_right_ylabel_color = color
|
|
198
|
+
artist = getattr(ax, '_right_ylabel_artist', None)
|
|
199
|
+
if artist is not None:
|
|
200
|
+
artist.set_color(color)
|
|
201
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
202
|
+
else:
|
|
203
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
_apply_stored_axis_colors(ax)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _diffcap_clean_series(x: np.ndarray, y: np.ndarray, min_step: float = 1e-3) -> Tuple[np.ndarray, np.ndarray, int]:
|
|
210
|
+
"""Remove points where ΔVoltage < min_step (default 1 mV) while preserving order."""
|
|
211
|
+
if x.size <= 1:
|
|
212
|
+
return x, y, 0
|
|
213
|
+
keep_indices = [0]
|
|
214
|
+
last_x = x[0]
|
|
215
|
+
removed = 0
|
|
216
|
+
for idx in range(1, x.size):
|
|
217
|
+
if abs(x[idx] - last_x) >= min_step:
|
|
218
|
+
keep_indices.append(idx)
|
|
219
|
+
last_x = x[idx]
|
|
220
|
+
else:
|
|
221
|
+
removed += 1
|
|
222
|
+
if removed == 0:
|
|
223
|
+
return x, y, 0
|
|
224
|
+
keep = np.array(keep_indices, dtype=int)
|
|
225
|
+
return x[keep], y[keep], removed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _savgol_kernel(window: int, poly: int) -> np.ndarray:
|
|
229
|
+
"""Return Savitzky–Golay smoothing kernel of given window/poly."""
|
|
230
|
+
half = window // 2
|
|
231
|
+
x = np.arange(-half, half + 1, dtype=float)
|
|
232
|
+
A = np.vander(x, poly + 1, increasing=True)
|
|
233
|
+
ATA = A.T @ A
|
|
234
|
+
ATA_inv = np.linalg.pinv(ATA)
|
|
235
|
+
target = np.zeros(poly + 1, dtype=float)
|
|
236
|
+
target[0] = 1.0 # evaluate polynomial at x=0
|
|
237
|
+
coeffs = target @ ATA_inv @ A.T
|
|
238
|
+
return coeffs
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _savgol_smooth(y: np.ndarray, window: int = 9, poly: int = 3) -> np.ndarray:
|
|
242
|
+
"""Apply Savitzky–Golay smoothing (defaults from DiffCapAnalyzer) to data."""
|
|
243
|
+
n = y.size
|
|
244
|
+
if n < 3:
|
|
245
|
+
return y
|
|
246
|
+
if window > n:
|
|
247
|
+
window = n if n % 2 == 1 else n - 1
|
|
248
|
+
if window < 3:
|
|
249
|
+
return y
|
|
250
|
+
if window % 2 == 0:
|
|
251
|
+
window -= 1
|
|
252
|
+
if window < 3:
|
|
253
|
+
return y
|
|
254
|
+
if poly >= window:
|
|
255
|
+
poly = window - 1
|
|
256
|
+
coeffs = _savgol_kernel(window, poly)
|
|
257
|
+
half = window // 2
|
|
258
|
+
padded = np.pad(y, (half, half), mode='edge')
|
|
259
|
+
smoothed = np.convolve(padded, coeffs[::-1], mode='valid')
|
|
260
|
+
return smoothed
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _apply_stored_smooth_settings(cycle_lines: Dict[int, Dict[str, Optional[object]]], fig) -> None:
|
|
264
|
+
"""Apply stored smooth settings to newly visible cycles that haven't been smoothed yet."""
|
|
265
|
+
if not hasattr(fig, '_dqdv_smooth_settings'):
|
|
266
|
+
return
|
|
267
|
+
settings = fig._dqdv_smooth_settings
|
|
268
|
+
if not settings:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
method = settings.get('method')
|
|
272
|
+
if method == 'diffcap':
|
|
273
|
+
min_step = settings.get('min_step', 0.001)
|
|
274
|
+
window = settings.get('window', 9)
|
|
275
|
+
poly = settings.get('poly', 3)
|
|
276
|
+
for cyc, parts in cycle_lines.items():
|
|
277
|
+
iter_parts = [(None, parts)] if not isinstance(parts, dict) else parts.items()
|
|
278
|
+
for role, ln in iter_parts:
|
|
279
|
+
if ln is None or not ln.get_visible():
|
|
280
|
+
continue
|
|
281
|
+
# Only apply if this cycle hasn't been smoothed yet
|
|
282
|
+
if hasattr(ln, '_smooth_applied') and ln._smooth_applied:
|
|
283
|
+
continue
|
|
284
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
285
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
286
|
+
if xdata.size < 3:
|
|
287
|
+
continue
|
|
288
|
+
# Get original data if available, otherwise use current data
|
|
289
|
+
if hasattr(ln, '_original_xdata'):
|
|
290
|
+
xdata = np.asarray(ln._original_xdata, float)
|
|
291
|
+
ydata = np.asarray(ln._original_ydata, float)
|
|
292
|
+
else:
|
|
293
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
294
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
295
|
+
x_clean, y_clean, removed = _diffcap_clean_series(xdata, ydata, min_step)
|
|
296
|
+
if x_clean.size < poly + 2:
|
|
297
|
+
continue
|
|
298
|
+
y_smooth = _savgol_smooth(y_clean, window, poly)
|
|
299
|
+
ln.set_xdata(x_clean)
|
|
300
|
+
ln.set_ydata(y_smooth)
|
|
301
|
+
ln._smooth_applied = True
|
|
302
|
+
elif method == 'voltage_step':
|
|
303
|
+
threshold_v = settings.get('threshold_v', 0.0005)
|
|
304
|
+
for cyc, parts in cycle_lines.items():
|
|
305
|
+
for role in ("charge", "discharge"):
|
|
306
|
+
ln = parts.get(role) if isinstance(parts, dict) else parts
|
|
307
|
+
if ln is None or not ln.get_visible():
|
|
308
|
+
continue
|
|
309
|
+
# Only apply if this cycle hasn't been smoothed yet
|
|
310
|
+
if hasattr(ln, '_smooth_applied') and ln._smooth_applied:
|
|
311
|
+
continue
|
|
312
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
313
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
314
|
+
if xdata.size < 3:
|
|
315
|
+
continue
|
|
316
|
+
# Get original data if available, otherwise use current data
|
|
317
|
+
if hasattr(ln, '_original_xdata'):
|
|
318
|
+
xdata = np.asarray(ln._original_xdata, float)
|
|
319
|
+
ydata = np.asarray(ln._original_ydata, float)
|
|
320
|
+
else:
|
|
321
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
322
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
323
|
+
dv = np.abs(np.diff(xdata))
|
|
324
|
+
mask = np.ones_like(xdata, dtype=bool)
|
|
325
|
+
mask[1:] &= dv >= threshold_v
|
|
326
|
+
mask[:-1] &= dv >= threshold_v
|
|
327
|
+
filtered_x = xdata[mask]
|
|
328
|
+
filtered_y = ydata[mask]
|
|
329
|
+
if len(filtered_x) < len(xdata):
|
|
330
|
+
ln.set_xdata(filtered_x)
|
|
331
|
+
ln.set_ydata(filtered_y)
|
|
332
|
+
ln._smooth_applied = True
|
|
333
|
+
elif method == 'outlier':
|
|
334
|
+
outlier_method = settings.get('outlier_method', '1')
|
|
335
|
+
threshold = settings.get('threshold', 5.0)
|
|
336
|
+
for cyc, parts in cycle_lines.items():
|
|
337
|
+
for role in ("charge", "discharge"):
|
|
338
|
+
ln = parts.get(role) if isinstance(parts, dict) else parts
|
|
339
|
+
if ln is None or not ln.get_visible():
|
|
340
|
+
continue
|
|
341
|
+
# Only apply if this cycle hasn't been smoothed yet
|
|
342
|
+
if hasattr(ln, '_smooth_applied') and ln._smooth_applied:
|
|
343
|
+
continue
|
|
344
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
345
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
346
|
+
if xdata.size < 5:
|
|
347
|
+
continue
|
|
348
|
+
# Get original data if available, otherwise use current data
|
|
349
|
+
if hasattr(ln, '_original_xdata'):
|
|
350
|
+
xdata = np.asarray(ln._original_xdata, float)
|
|
351
|
+
ydata = np.asarray(ln._original_ydata, float)
|
|
352
|
+
else:
|
|
353
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
354
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
355
|
+
if outlier_method == '1':
|
|
356
|
+
mean_y = np.nanmean(ydata)
|
|
357
|
+
std_y = np.nanstd(ydata)
|
|
358
|
+
if not np.isfinite(std_y) or std_y == 0:
|
|
359
|
+
continue
|
|
360
|
+
zscores = np.abs((ydata - mean_y) / std_y)
|
|
361
|
+
mask = zscores <= threshold
|
|
362
|
+
else:
|
|
363
|
+
median_y = np.nanmedian(ydata)
|
|
364
|
+
mad = np.nanmedian(np.abs(ydata - median_y))
|
|
365
|
+
if not np.isfinite(mad) or mad == 0:
|
|
366
|
+
continue
|
|
367
|
+
deviations = np.abs(ydata - median_y) / mad
|
|
368
|
+
mask = deviations <= threshold
|
|
369
|
+
filtered_x = xdata[mask]
|
|
370
|
+
filtered_y = ydata[mask]
|
|
371
|
+
if len(filtered_x) < len(xdata):
|
|
372
|
+
ln.set_xdata(filtered_x)
|
|
373
|
+
ln.set_ydata(filtered_y)
|
|
374
|
+
ln._smooth_applied = True
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _print_menu(n_cycles: int, is_dqdv: bool = False):
|
|
378
|
+
# Three-column menu similar to operando: Styles | Geometries | Options
|
|
379
|
+
# Use dynamic column widths for clean alignment.
|
|
380
|
+
col1 = [
|
|
381
|
+
"f: font",
|
|
382
|
+
"l: line",
|
|
383
|
+
"k: spine colors",
|
|
384
|
+
"t: toggle axes",
|
|
385
|
+
"h: legend",
|
|
386
|
+
"g: size",
|
|
387
|
+
]
|
|
388
|
+
if is_dqdv:
|
|
389
|
+
col1.insert(2, "sm: smooth")
|
|
390
|
+
col2 = [
|
|
391
|
+
"c: cycles/colors",
|
|
392
|
+
"r: rename axes",
|
|
393
|
+
"x: x-scale",
|
|
394
|
+
"y: y-scale",
|
|
395
|
+
]
|
|
396
|
+
# Only show capacity/ion option when NOT in dQdV mode
|
|
397
|
+
if not is_dqdv:
|
|
398
|
+
col2.insert(1, "a: capacity/ion")
|
|
399
|
+
|
|
400
|
+
col3 = [
|
|
401
|
+
"p: print(export) style/geom",
|
|
402
|
+
"i: import style/geom",
|
|
403
|
+
"e: export figure",
|
|
404
|
+
"s: save project",
|
|
405
|
+
"b: undo",
|
|
406
|
+
"q: quit",
|
|
407
|
+
]
|
|
408
|
+
# Compute widths (min width prevents overly narrow columns)
|
|
409
|
+
w1 = max(len("(Styles)"), *(len(s) for s in col1), 18)
|
|
410
|
+
w2 = max(len("(Geometries)"), *(len(s) for s in col2), 12)
|
|
411
|
+
w3 = max(len("(Options)"), *(len(s) for s in col3), 12)
|
|
412
|
+
rows = max(len(col1), len(col2), len(col3))
|
|
413
|
+
print("\n\033[1mInteractive menu:\033[0m") # Bold title
|
|
414
|
+
print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
|
|
415
|
+
for i in range(rows):
|
|
416
|
+
p1 = _colorize_menu(col1[i]) if i < len(col1) else ""
|
|
417
|
+
p2 = _colorize_menu(col2[i]) if i < len(col2) else ""
|
|
418
|
+
p3 = _colorize_menu(col3[i]) if i < len(col3) else ""
|
|
419
|
+
# Add padding to account for ANSI escape codes
|
|
420
|
+
pad1 = w1 + (9 if i < len(col1) else 0)
|
|
421
|
+
pad2 = w2 + (9 if i < len(col2) else 0)
|
|
422
|
+
pad3 = w3 + (9 if i < len(col3) else 0)
|
|
423
|
+
print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _iter_cycle_lines(cycle_lines: Dict[int, Dict[str, Optional[object]]]):
|
|
427
|
+
"""Iterate over all Line2D objects in cycle_lines, handling both GC and CV modes.
|
|
428
|
+
|
|
429
|
+
Yields: (cyc, role_or_None, Line2D) tuples
|
|
430
|
+
- For GC mode: yields (cyc, 'charge', ln) and (cyc, 'discharge', ln) for each cycle
|
|
431
|
+
- For CV mode: yields (cyc, None, ln) for each cycle
|
|
432
|
+
"""
|
|
433
|
+
for cyc, parts in cycle_lines.items():
|
|
434
|
+
if not isinstance(parts, dict):
|
|
435
|
+
# CV mode: parts is a Line2D directly
|
|
436
|
+
yield (cyc, None, parts)
|
|
437
|
+
else:
|
|
438
|
+
# GC mode: parts is a dict with 'charge' and 'discharge' keys
|
|
439
|
+
for role in ("charge", "discharge"):
|
|
440
|
+
ln = parts.get(role)
|
|
441
|
+
if ln is not None:
|
|
442
|
+
yield (cyc, role, ln)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _visible_legend_entries(ax):
|
|
446
|
+
"""Return handles/labels for visible, user-facing lines only."""
|
|
447
|
+
handles = []
|
|
448
|
+
labels = []
|
|
449
|
+
for ln in ax.lines:
|
|
450
|
+
if ln.get_visible():
|
|
451
|
+
lab = ln.get_label() or ""
|
|
452
|
+
if lab.startswith("_"):
|
|
453
|
+
continue
|
|
454
|
+
handles.append(ln)
|
|
455
|
+
labels.append(lab)
|
|
456
|
+
return handles, labels
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _get_legend_user_pref(fig):
|
|
460
|
+
try:
|
|
461
|
+
return bool(getattr(fig, '_ec_legend_user_visible'))
|
|
462
|
+
except Exception:
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _set_legend_user_pref(fig, visible: bool):
|
|
467
|
+
try:
|
|
468
|
+
fig._ec_legend_user_visible = bool(visible)
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _store_legend_title(fig, ax, fallback: str = "Cycle"):
|
|
474
|
+
"""Persist the current legend title on the figure for later rebuilds."""
|
|
475
|
+
try:
|
|
476
|
+
leg = ax.get_legend()
|
|
477
|
+
text = ""
|
|
478
|
+
if leg is not None:
|
|
479
|
+
title_artist = leg.get_title()
|
|
480
|
+
if title_artist is not None:
|
|
481
|
+
text = title_artist.get_text() or ""
|
|
482
|
+
if text:
|
|
483
|
+
fig._ec_legend_title = text
|
|
484
|
+
elif not getattr(fig, '_ec_legend_title', None):
|
|
485
|
+
fig._ec_legend_title = fallback
|
|
486
|
+
except Exception:
|
|
487
|
+
if not getattr(fig, '_ec_legend_title', None):
|
|
488
|
+
fig._ec_legend_title = fallback
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _get_legend_title(fig, default: str = "Cycle") -> str:
|
|
492
|
+
try:
|
|
493
|
+
title = getattr(fig, '_ec_legend_title')
|
|
494
|
+
if isinstance(title, str) and title:
|
|
495
|
+
return title
|
|
496
|
+
except Exception:
|
|
497
|
+
pass
|
|
498
|
+
return default
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _rebuild_legend(ax):
|
|
502
|
+
"""Rebuild legend using only visible lines, anchoring to absolute inches from canvas center if available."""
|
|
503
|
+
fig = ax.figure
|
|
504
|
+
# Capture existing title before any rebuild so it isn't lost
|
|
505
|
+
_store_legend_title(fig, ax)
|
|
506
|
+
# If no stored position yet, try to capture the current legend location once
|
|
507
|
+
# so rebuilds (e.g., after renaming) don't jump to a new "best" spot.
|
|
508
|
+
try:
|
|
509
|
+
if getattr(fig, '_ec_legend_xy_in', None) is None:
|
|
510
|
+
leg0 = ax.get_legend()
|
|
511
|
+
if leg0 is not None and leg0.get_visible():
|
|
512
|
+
try:
|
|
513
|
+
renderer = fig.canvas.get_renderer()
|
|
514
|
+
except Exception:
|
|
515
|
+
fig.canvas.draw()
|
|
516
|
+
renderer = fig.canvas.get_renderer()
|
|
517
|
+
bb = leg0.get_window_extent(renderer=renderer)
|
|
518
|
+
cx = 0.5 * (bb.x0 + bb.x1)
|
|
519
|
+
cy = 0.5 * (bb.y0 + bb.y1)
|
|
520
|
+
fx, fy = fig.transFigure.inverted().transform((cx, cy))
|
|
521
|
+
fw, fh = fig.get_size_inches()
|
|
522
|
+
offset = ((fx - 0.5) * fw, (fy - 0.5) * fh)
|
|
523
|
+
offset = _sanitize_legend_offset(fig, offset)
|
|
524
|
+
if offset is not None:
|
|
525
|
+
fig._ec_legend_xy_in = offset
|
|
526
|
+
except Exception:
|
|
527
|
+
pass
|
|
528
|
+
if not _get_legend_user_pref(fig):
|
|
529
|
+
leg = ax.get_legend()
|
|
530
|
+
if leg is not None:
|
|
531
|
+
try:
|
|
532
|
+
leg.remove()
|
|
533
|
+
except Exception:
|
|
534
|
+
pass
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
handles, labels = _visible_legend_entries(ax)
|
|
538
|
+
if handles:
|
|
539
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', None))
|
|
540
|
+
legend_title = _get_legend_title(fig)
|
|
541
|
+
if xy_in is not None:
|
|
542
|
+
try:
|
|
543
|
+
fw, fh = fig.get_size_inches()
|
|
544
|
+
fx = 0.5 + float(xy_in[0]) / float(fw)
|
|
545
|
+
fy = 0.5 + float(xy_in[1]) / float(fh)
|
|
546
|
+
_legend_no_frame(
|
|
547
|
+
ax,
|
|
548
|
+
handles,
|
|
549
|
+
labels,
|
|
550
|
+
loc='center',
|
|
551
|
+
bbox_to_anchor=(fx, fy),
|
|
552
|
+
bbox_transform=fig.transFigure,
|
|
553
|
+
borderaxespad=1.0,
|
|
554
|
+
title=legend_title,
|
|
555
|
+
)
|
|
556
|
+
except Exception:
|
|
557
|
+
_legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0, title=legend_title)
|
|
558
|
+
else:
|
|
559
|
+
_legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0, title=legend_title)
|
|
560
|
+
_store_legend_title(fig, ax, legend_title)
|
|
561
|
+
else:
|
|
562
|
+
leg = ax.get_legend()
|
|
563
|
+
if leg is not None:
|
|
564
|
+
try:
|
|
565
|
+
leg.remove()
|
|
566
|
+
except Exception:
|
|
567
|
+
pass
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _apply_curve_linewidth(fig, cycle_lines: Dict[int, Dict[str, Optional[object]]]):
|
|
571
|
+
"""Apply stored curve linewidth to all curves.
|
|
572
|
+
|
|
573
|
+
Handles both GC mode (dict with 'charge'/'discharge' keys) and CV mode (direct Line2D).
|
|
574
|
+
"""
|
|
575
|
+
lw = getattr(fig, '_ec_curve_linewidth', None)
|
|
576
|
+
if lw is not None:
|
|
577
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
578
|
+
try:
|
|
579
|
+
ln.set_linewidth(lw)
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _apply_colors(cycle_lines: Dict[int, Dict[str, Optional[object]]], mapping: Dict[int, object]):
|
|
585
|
+
"""Apply color mapping to charge/discharge lines for the given cycles.
|
|
586
|
+
|
|
587
|
+
Handles both GC mode (dict with 'charge'/'discharge' keys) and CV mode (direct Line2D).
|
|
588
|
+
"""
|
|
589
|
+
for cyc, col in mapping.items():
|
|
590
|
+
if cyc not in cycle_lines:
|
|
591
|
+
continue
|
|
592
|
+
for _, _, ln in _iter_cycle_lines({cyc: cycle_lines[cyc]}):
|
|
593
|
+
try:
|
|
594
|
+
ln.set_color(col)
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _set_visible_cycles(cycle_lines: Dict[int, Dict[str, Optional[object]]], show: Iterable[int]):
|
|
600
|
+
"""Set visibility for specified cycles.
|
|
601
|
+
|
|
602
|
+
Handles both GC mode (dict with 'charge'/'discharge' keys) and CV mode (direct Line2D).
|
|
603
|
+
"""
|
|
604
|
+
show_set = set(show)
|
|
605
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
606
|
+
vis = cyc in show_set
|
|
607
|
+
try:
|
|
608
|
+
ln.set_visible(vis)
|
|
609
|
+
except Exception:
|
|
610
|
+
pass
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _resolve_palette_alias(token: str, palette_map: dict) -> str:
|
|
614
|
+
"""Resolve numeric aliases (e.g., '2' or '2_r') to palette names."""
|
|
615
|
+
suffix = ''
|
|
616
|
+
base = token
|
|
617
|
+
if token.lower().endswith('_r'):
|
|
618
|
+
suffix = '_r'
|
|
619
|
+
base = token[:-2]
|
|
620
|
+
if base in palette_map:
|
|
621
|
+
return palette_map[base] + suffix
|
|
622
|
+
return token
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _parse_cycle_tokens(tokens: List[str], fig=None) -> Tuple[str, List[int], dict, Optional[str], bool]:
|
|
626
|
+
"""Classify and parse tokens for the cycle command.
|
|
627
|
+
|
|
628
|
+
Returns a tuple: (mode, cycles, mapping, palette)
|
|
629
|
+
- mode: 'map' for explicit mappings like 1:red, 'palette' for numbers + cmap,
|
|
630
|
+
'numbers' for numbers only.
|
|
631
|
+
- cycles: list of cycle indices (integers)
|
|
632
|
+
- mapping: dict for 'map' mode only, empty otherwise
|
|
633
|
+
- palette: colormap name for 'palette' mode else None
|
|
634
|
+
"""
|
|
635
|
+
if not tokens:
|
|
636
|
+
return ("numbers", [], {}, None, False)
|
|
637
|
+
|
|
638
|
+
palette_map = {
|
|
639
|
+
'1': 'tab10',
|
|
640
|
+
'2': 'Set2',
|
|
641
|
+
'3': 'Dark2',
|
|
642
|
+
'4': 'viridis',
|
|
643
|
+
'5': 'plasma'
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
# Support 'all' and 'all <palette>'
|
|
647
|
+
if len(tokens) == 1 and tokens[0].lower() == 'all':
|
|
648
|
+
return ("numbers", [], {}, None, True)
|
|
649
|
+
if len(tokens) == 2 and tokens[0].lower() == 'all':
|
|
650
|
+
alias = _resolve_palette_alias(tokens[1], palette_map)
|
|
651
|
+
try:
|
|
652
|
+
cm.get_cmap(alias)
|
|
653
|
+
return ("palette", [], {}, alias, True)
|
|
654
|
+
except Exception:
|
|
655
|
+
# Unknown palette -> still select all, no recolor
|
|
656
|
+
return ("numbers", [], {}, None, True)
|
|
657
|
+
|
|
658
|
+
# Check explicit mapping mode first
|
|
659
|
+
if any(":" in t for t in tokens):
|
|
660
|
+
cycles: List[int] = []
|
|
661
|
+
mapping = {}
|
|
662
|
+
for t in tokens:
|
|
663
|
+
if ":" not in t:
|
|
664
|
+
continue
|
|
665
|
+
idx_s, col = t.split(":", 1)
|
|
666
|
+
try:
|
|
667
|
+
cyc = int(idx_s)
|
|
668
|
+
except ValueError:
|
|
669
|
+
continue
|
|
670
|
+
mapping[cyc] = resolve_color_token(col, fig)
|
|
671
|
+
if cyc not in cycles:
|
|
672
|
+
cycles.append(cyc)
|
|
673
|
+
return ("map", cycles, mapping, None, False)
|
|
674
|
+
|
|
675
|
+
# If last token is a valid colormap or number (1-5) -> palette mode
|
|
676
|
+
last = tokens[-1]
|
|
677
|
+
|
|
678
|
+
# Check if last token is a number from 1-5
|
|
679
|
+
if last in palette_map:
|
|
680
|
+
palette = palette_map[last]
|
|
681
|
+
num_tokens = tokens[:-1]
|
|
682
|
+
cycles = []
|
|
683
|
+
for t in num_tokens:
|
|
684
|
+
try:
|
|
685
|
+
cycles.append(int(t))
|
|
686
|
+
except ValueError:
|
|
687
|
+
pass
|
|
688
|
+
return ("palette", cycles, {}, palette, False)
|
|
689
|
+
alias = _resolve_palette_alias(last, palette_map)
|
|
690
|
+
if alias != last:
|
|
691
|
+
try:
|
|
692
|
+
cm.get_cmap(alias)
|
|
693
|
+
palette = alias
|
|
694
|
+
num_tokens = tokens[:-1]
|
|
695
|
+
cycles = []
|
|
696
|
+
for t in num_tokens:
|
|
697
|
+
try:
|
|
698
|
+
cycles.append(int(t))
|
|
699
|
+
except ValueError:
|
|
700
|
+
pass
|
|
701
|
+
return ("palette", cycles, {}, palette, False)
|
|
702
|
+
except Exception:
|
|
703
|
+
pass
|
|
704
|
+
|
|
705
|
+
# Check if last token is a valid colormap name
|
|
706
|
+
try:
|
|
707
|
+
cm.get_cmap(last)
|
|
708
|
+
palette = last
|
|
709
|
+
num_tokens = tokens[:-1]
|
|
710
|
+
cycles = []
|
|
711
|
+
for t in num_tokens:
|
|
712
|
+
try:
|
|
713
|
+
cycles.append(int(t))
|
|
714
|
+
except ValueError:
|
|
715
|
+
pass
|
|
716
|
+
return ("palette", cycles, {}, palette, False)
|
|
717
|
+
except Exception:
|
|
718
|
+
pass
|
|
719
|
+
|
|
720
|
+
# Numbers only
|
|
721
|
+
cycles: List[int] = []
|
|
722
|
+
for t in tokens:
|
|
723
|
+
try:
|
|
724
|
+
cycles.append(int(t))
|
|
725
|
+
except ValueError:
|
|
726
|
+
pass
|
|
727
|
+
return ("numbers", cycles, {}, None, False)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _apply_font_family(ax, family: str):
|
|
731
|
+
try:
|
|
732
|
+
import matplotlib as mpl
|
|
733
|
+
# Update defaults for any new text
|
|
734
|
+
mpl.rcParams['font.family'] = family
|
|
735
|
+
# Configure mathtext to use the same font family
|
|
736
|
+
lf = family.lower()
|
|
737
|
+
if any(k in lf for k in ('stix', 'times', 'roman')):
|
|
738
|
+
mpl.rcParams['mathtext.fontset'] = 'stix'
|
|
739
|
+
else:
|
|
740
|
+
# Use dejavusans for Arial, Helvetica, etc. to match sans-serif fonts
|
|
741
|
+
mpl.rcParams['mathtext.fontset'] = 'dejavusans'
|
|
742
|
+
mpl.rcParams['mathtext.default'] = 'regular'
|
|
743
|
+
# Apply to existing labels
|
|
744
|
+
try:
|
|
745
|
+
ax.xaxis.label.set_family(family)
|
|
746
|
+
except Exception:
|
|
747
|
+
pass
|
|
748
|
+
try:
|
|
749
|
+
ax.yaxis.label.set_family(family)
|
|
750
|
+
except Exception:
|
|
751
|
+
pass
|
|
752
|
+
# Title (safe if exists)
|
|
753
|
+
try:
|
|
754
|
+
ax.title.set_family(family)
|
|
755
|
+
except Exception:
|
|
756
|
+
pass
|
|
757
|
+
# Duplicate titles
|
|
758
|
+
try:
|
|
759
|
+
art = getattr(ax, '_top_xlabel_artist', None)
|
|
760
|
+
if art is not None:
|
|
761
|
+
art.set_family(family)
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
try:
|
|
765
|
+
art = getattr(ax, '_right_ylabel_artist', None)
|
|
766
|
+
if art is not None:
|
|
767
|
+
art.set_family(family)
|
|
768
|
+
except Exception:
|
|
769
|
+
pass
|
|
770
|
+
# Ticks
|
|
771
|
+
for lab in list(ax.get_xticklabels()) + list(ax.get_yticklabels()):
|
|
772
|
+
try:
|
|
773
|
+
lab.set_family(family)
|
|
774
|
+
except Exception:
|
|
775
|
+
pass
|
|
776
|
+
# Top/right tick labels (label2)
|
|
777
|
+
try:
|
|
778
|
+
for t in ax.xaxis.get_major_ticks():
|
|
779
|
+
if hasattr(t, 'label2'):
|
|
780
|
+
t.label2.set_family(family)
|
|
781
|
+
for t in ax.yaxis.get_major_ticks():
|
|
782
|
+
if hasattr(t, 'label2'):
|
|
783
|
+
t.label2.set_family(family)
|
|
784
|
+
except Exception:
|
|
785
|
+
pass
|
|
786
|
+
# Legend
|
|
787
|
+
leg = ax.get_legend()
|
|
788
|
+
if leg is not None:
|
|
789
|
+
for t in leg.get_texts():
|
|
790
|
+
try:
|
|
791
|
+
t.set_family(family)
|
|
792
|
+
except Exception:
|
|
793
|
+
pass
|
|
794
|
+
# Any additional text in axes
|
|
795
|
+
for t in getattr(ax, 'texts', []):
|
|
796
|
+
try:
|
|
797
|
+
t.set_family(family)
|
|
798
|
+
except Exception:
|
|
799
|
+
pass
|
|
800
|
+
except Exception:
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _apply_font_size(ax, size: float):
|
|
805
|
+
"""Apply font size to all text elements on the axes."""
|
|
806
|
+
try:
|
|
807
|
+
import matplotlib as mpl
|
|
808
|
+
# Update defaults for any new text
|
|
809
|
+
mpl.rcParams['font.size'] = size
|
|
810
|
+
# Labels
|
|
811
|
+
try:
|
|
812
|
+
ax.xaxis.label.set_size(size)
|
|
813
|
+
except Exception:
|
|
814
|
+
pass
|
|
815
|
+
try:
|
|
816
|
+
ax.yaxis.label.set_size(size)
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
# Title (safe if exists)
|
|
820
|
+
try:
|
|
821
|
+
ax.title.set_size(size)
|
|
822
|
+
except Exception:
|
|
823
|
+
pass
|
|
824
|
+
# Duplicate titles
|
|
825
|
+
try:
|
|
826
|
+
art = getattr(ax, '_top_xlabel_artist', None)
|
|
827
|
+
if art is not None:
|
|
828
|
+
art.set_size(size)
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
try:
|
|
832
|
+
art = getattr(ax, '_right_ylabel_artist', None)
|
|
833
|
+
if art is not None:
|
|
834
|
+
art.set_size(size)
|
|
835
|
+
except Exception:
|
|
836
|
+
pass
|
|
837
|
+
# Ticks
|
|
838
|
+
for lab in list(ax.get_xticklabels()) + list(ax.get_yticklabels()):
|
|
839
|
+
try:
|
|
840
|
+
lab.set_size(size)
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
# Also update top/right tick labels (label2)
|
|
844
|
+
try:
|
|
845
|
+
for t in ax.xaxis.get_major_ticks():
|
|
846
|
+
if hasattr(t, 'label2'):
|
|
847
|
+
t.label2.set_size(size)
|
|
848
|
+
for t in ax.yaxis.get_major_ticks():
|
|
849
|
+
if hasattr(t, 'label2'):
|
|
850
|
+
t.label2.set_size(size)
|
|
851
|
+
except Exception:
|
|
852
|
+
pass
|
|
853
|
+
except Exception:
|
|
854
|
+
pass
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def electrochem_interactive_menu(fig, ax, cycle_lines: Dict[int, Dict[str, Optional[object]]], file_path=None):
|
|
858
|
+
# --- Tick/label state and helpers (similar to normal XY menu) ---
|
|
859
|
+
tick_state = getattr(ax, '_saved_tick_state', {
|
|
860
|
+
'bx': True,
|
|
861
|
+
'tx': False,
|
|
862
|
+
'ly': True,
|
|
863
|
+
'ry': False,
|
|
864
|
+
'mbx': False,
|
|
865
|
+
'mtx': False,
|
|
866
|
+
'mly': False,
|
|
867
|
+
'mry': False,
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
base_ylabel = ax.get_ylabel() or ''
|
|
871
|
+
if not hasattr(ax, '_stored_xlabel'):
|
|
872
|
+
ax._stored_xlabel = ax.get_xlabel() or ''
|
|
873
|
+
if not hasattr(ax, '_stored_ylabel'):
|
|
874
|
+
ax._stored_ylabel = base_ylabel
|
|
875
|
+
if not hasattr(ax, '_stored_xlabel_color'):
|
|
876
|
+
try:
|
|
877
|
+
ax._stored_xlabel_color = ax.xaxis.label.get_color()
|
|
878
|
+
except Exception:
|
|
879
|
+
ax._stored_xlabel_color = None
|
|
880
|
+
if not hasattr(ax, '_stored_ylabel_color'):
|
|
881
|
+
try:
|
|
882
|
+
ax._stored_ylabel_color = ax.yaxis.label.get_color()
|
|
883
|
+
except Exception:
|
|
884
|
+
ax._stored_ylabel_color = None
|
|
885
|
+
if not hasattr(ax, '_stored_top_xlabel_color'):
|
|
886
|
+
ax._stored_top_xlabel_color = ax.xaxis.label.get_color()
|
|
887
|
+
if not hasattr(ax, '_stored_right_ylabel_color'):
|
|
888
|
+
ax._stored_right_ylabel_color = ax.yaxis.label.get_color()
|
|
889
|
+
|
|
890
|
+
# Detect dQdV mode: check stored flag first, then fall back to y-label detection
|
|
891
|
+
# This handles cases where the user renamed the y-axis and saved/reloaded the session
|
|
892
|
+
is_dqdv = getattr(ax, '_is_dqdv_mode', None)
|
|
893
|
+
if is_dqdv is None:
|
|
894
|
+
# Initial detection: check if y-label contains "dQ"
|
|
895
|
+
is_dqdv = 'dQ' in base_ylabel
|
|
896
|
+
# Store the mode on the axes for persistence
|
|
897
|
+
ax._is_dqdv_mode = is_dqdv
|
|
898
|
+
|
|
899
|
+
# Store original x/y limits for 'auto' command (restore to original data range)
|
|
900
|
+
if not hasattr(ax, '_original_xlim'):
|
|
901
|
+
# Get original limits from all cycle lines
|
|
902
|
+
try:
|
|
903
|
+
all_x = []
|
|
904
|
+
all_y = []
|
|
905
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
906
|
+
try:
|
|
907
|
+
xd = np.asarray(ln.get_xdata(), dtype=float)
|
|
908
|
+
yd = np.asarray(ln.get_ydata(), dtype=float)
|
|
909
|
+
if xd.size > 0:
|
|
910
|
+
all_x.extend([xd.min(), xd.max()])
|
|
911
|
+
if yd.size > 0:
|
|
912
|
+
all_y.extend([yd.min(), yd.max()])
|
|
913
|
+
except Exception:
|
|
914
|
+
pass
|
|
915
|
+
if all_x:
|
|
916
|
+
ax._original_xlim = (min(all_x), max(all_x))
|
|
917
|
+
else:
|
|
918
|
+
ax._original_xlim = ax.get_xlim()
|
|
919
|
+
if all_y:
|
|
920
|
+
ax._original_ylim = (min(all_y), max(all_y))
|
|
921
|
+
else:
|
|
922
|
+
ax._original_ylim = ax.get_ylim()
|
|
923
|
+
except Exception:
|
|
924
|
+
ax._original_xlim = ax.get_xlim()
|
|
925
|
+
ax._original_ylim = ax.get_ylim()
|
|
926
|
+
|
|
927
|
+
source_paths = []
|
|
928
|
+
_source_seen = set()
|
|
929
|
+
|
|
930
|
+
def _add_source_path(path_val):
|
|
931
|
+
if not path_val:
|
|
932
|
+
return
|
|
933
|
+
try:
|
|
934
|
+
abs_path = os.path.abspath(path_val)
|
|
935
|
+
except Exception:
|
|
936
|
+
return
|
|
937
|
+
if not os.path.exists(abs_path):
|
|
938
|
+
return
|
|
939
|
+
if abs_path in _source_seen:
|
|
940
|
+
return
|
|
941
|
+
_source_seen.add(abs_path)
|
|
942
|
+
source_paths.append(abs_path)
|
|
943
|
+
|
|
944
|
+
if file_path:
|
|
945
|
+
_add_source_path(file_path)
|
|
946
|
+
fig_source_attr = getattr(fig, '_bp_source_paths', None)
|
|
947
|
+
if fig_source_attr:
|
|
948
|
+
for _p in fig_source_attr:
|
|
949
|
+
_add_source_path(_p)
|
|
950
|
+
if not source_paths and hasattr(ax, 'figure'):
|
|
951
|
+
attr = getattr(ax.figure, '_bp_source_paths', None)
|
|
952
|
+
if attr:
|
|
953
|
+
for _p in attr:
|
|
954
|
+
_add_source_path(_p)
|
|
955
|
+
try:
|
|
956
|
+
fig._bp_source_paths = list(source_paths)
|
|
957
|
+
except Exception:
|
|
958
|
+
pass
|
|
959
|
+
|
|
960
|
+
def _set_spine_visible(which: str, visible: bool):
|
|
961
|
+
sp = ax.spines.get(which)
|
|
962
|
+
if sp is not None:
|
|
963
|
+
try:
|
|
964
|
+
sp.set_visible(bool(visible))
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
|
|
968
|
+
def _get_spine_visible(which: str) -> bool:
|
|
969
|
+
sp = ax.spines.get(which)
|
|
970
|
+
try:
|
|
971
|
+
return bool(sp.get_visible()) if sp is not None else False
|
|
972
|
+
except Exception:
|
|
973
|
+
return False
|
|
974
|
+
|
|
975
|
+
def _update_tick_visibility():
|
|
976
|
+
# Use shared UI helper for consistent behavior
|
|
977
|
+
try:
|
|
978
|
+
_ui_update_tick_visibility(ax, tick_state)
|
|
979
|
+
except Exception:
|
|
980
|
+
pass
|
|
981
|
+
# Persist on axes
|
|
982
|
+
try:
|
|
983
|
+
ax._saved_tick_state = dict(tick_state)
|
|
984
|
+
except Exception:
|
|
985
|
+
pass
|
|
986
|
+
# Keep label spacing consistent with XY behavior
|
|
987
|
+
try:
|
|
988
|
+
_ui_position_bottom_xlabel(ax, ax.figure, tick_state)
|
|
989
|
+
_ui_position_left_ylabel(ax, ax.figure, tick_state)
|
|
990
|
+
except Exception:
|
|
991
|
+
pass
|
|
992
|
+
|
|
993
|
+
def _title_offset_menu():
|
|
994
|
+
"""Allow nudging duplicate top/right titles by single-pixel increments."""
|
|
995
|
+
# Import UI positioning functions locally to ensure they're accessible in nested functions
|
|
996
|
+
from .ui import position_top_xlabel as _ui_position_top_xlabel, position_bottom_xlabel as _ui_position_bottom_xlabel, position_left_ylabel as _ui_position_left_ylabel, position_right_ylabel as _ui_position_right_ylabel
|
|
997
|
+
|
|
998
|
+
def _dpi():
|
|
999
|
+
try:
|
|
1000
|
+
return float(fig.dpi)
|
|
1001
|
+
except Exception:
|
|
1002
|
+
return 72.0
|
|
1003
|
+
|
|
1004
|
+
def _px_value(attr):
|
|
1005
|
+
try:
|
|
1006
|
+
pts = float(getattr(ax, attr, 0.0) or 0.0)
|
|
1007
|
+
except Exception:
|
|
1008
|
+
pts = 0.0
|
|
1009
|
+
return pts * _dpi() / 72.0
|
|
1010
|
+
|
|
1011
|
+
def _set_attr(attr, pts):
|
|
1012
|
+
try:
|
|
1013
|
+
setattr(ax, attr, float(pts))
|
|
1014
|
+
except Exception:
|
|
1015
|
+
pass
|
|
1016
|
+
|
|
1017
|
+
def _nudge(attr, delta_px):
|
|
1018
|
+
try:
|
|
1019
|
+
current_pts = float(getattr(ax, attr, 0.0) or 0.0)
|
|
1020
|
+
except Exception:
|
|
1021
|
+
current_pts = 0.0
|
|
1022
|
+
delta_pts = float(delta_px) * 72.0 / _dpi()
|
|
1023
|
+
_set_attr(attr, current_pts + delta_pts)
|
|
1024
|
+
|
|
1025
|
+
snapshot_taken = False
|
|
1026
|
+
|
|
1027
|
+
def _ensure_snapshot():
|
|
1028
|
+
nonlocal snapshot_taken
|
|
1029
|
+
if not snapshot_taken:
|
|
1030
|
+
push_state("title-offset")
|
|
1031
|
+
snapshot_taken = True
|
|
1032
|
+
|
|
1033
|
+
def _top_menu():
|
|
1034
|
+
if not getattr(ax, '_top_xlabel_on', False):
|
|
1035
|
+
print("Top duplicate title is currently hidden (enable with w5).")
|
|
1036
|
+
return
|
|
1037
|
+
while True:
|
|
1038
|
+
current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
|
|
1039
|
+
current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
|
|
1040
|
+
print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
|
|
1041
|
+
sub = _safe_input(_colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
|
|
1042
|
+
if not sub:
|
|
1043
|
+
continue
|
|
1044
|
+
if sub == 'q':
|
|
1045
|
+
break
|
|
1046
|
+
if sub == '0':
|
|
1047
|
+
_ensure_snapshot()
|
|
1048
|
+
_set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
|
|
1049
|
+
_set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
|
|
1050
|
+
elif sub == 'w':
|
|
1051
|
+
_ensure_snapshot()
|
|
1052
|
+
_nudge('_top_xlabel_manual_offset_y_pts', +1.0)
|
|
1053
|
+
elif sub == 's':
|
|
1054
|
+
_ensure_snapshot()
|
|
1055
|
+
_nudge('_top_xlabel_manual_offset_y_pts', -1.0)
|
|
1056
|
+
elif sub == 'a':
|
|
1057
|
+
_ensure_snapshot()
|
|
1058
|
+
_nudge('_top_xlabel_manual_offset_x_pts', -1.0)
|
|
1059
|
+
elif sub == 'd':
|
|
1060
|
+
_ensure_snapshot()
|
|
1061
|
+
_nudge('_top_xlabel_manual_offset_x_pts', +1.0)
|
|
1062
|
+
else:
|
|
1063
|
+
print("Unknown choice (use w/s/a/d/0/q).")
|
|
1064
|
+
continue
|
|
1065
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
1066
|
+
try:
|
|
1067
|
+
fig.canvas.draw_idle()
|
|
1068
|
+
except Exception:
|
|
1069
|
+
pass
|
|
1070
|
+
|
|
1071
|
+
def _right_menu():
|
|
1072
|
+
if not getattr(ax, '_right_ylabel_on', False):
|
|
1073
|
+
print("Right duplicate title is currently hidden (enable with d5).")
|
|
1074
|
+
return
|
|
1075
|
+
while True:
|
|
1076
|
+
current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
|
|
1077
|
+
current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
|
|
1078
|
+
print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
|
|
1079
|
+
sub = _safe_input(_colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
|
|
1080
|
+
if not sub:
|
|
1081
|
+
continue
|
|
1082
|
+
if sub == 'q':
|
|
1083
|
+
break
|
|
1084
|
+
if sub == '0':
|
|
1085
|
+
_ensure_snapshot()
|
|
1086
|
+
_set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
|
|
1087
|
+
_set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
|
|
1088
|
+
elif sub == 'd':
|
|
1089
|
+
_ensure_snapshot()
|
|
1090
|
+
_nudge('_right_ylabel_manual_offset_x_pts', +1.0)
|
|
1091
|
+
elif sub == 'a':
|
|
1092
|
+
_ensure_snapshot()
|
|
1093
|
+
_nudge('_right_ylabel_manual_offset_x_pts', -1.0)
|
|
1094
|
+
elif sub == 'w':
|
|
1095
|
+
_ensure_snapshot()
|
|
1096
|
+
_nudge('_right_ylabel_manual_offset_y_pts', +1.0)
|
|
1097
|
+
elif sub == 's':
|
|
1098
|
+
_ensure_snapshot()
|
|
1099
|
+
_nudge('_right_ylabel_manual_offset_y_pts', -1.0)
|
|
1100
|
+
else:
|
|
1101
|
+
print("Unknown choice (use d/a/w/s/0/q).")
|
|
1102
|
+
continue
|
|
1103
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
1104
|
+
try:
|
|
1105
|
+
fig.canvas.draw_idle()
|
|
1106
|
+
except Exception:
|
|
1107
|
+
pass
|
|
1108
|
+
|
|
1109
|
+
def _bottom_menu():
|
|
1110
|
+
if not ax.get_xlabel():
|
|
1111
|
+
print("Bottom title is currently hidden.")
|
|
1112
|
+
return
|
|
1113
|
+
while True:
|
|
1114
|
+
current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
|
|
1115
|
+
print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
|
|
1116
|
+
sub = _safe_input(_colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
|
|
1117
|
+
if not sub:
|
|
1118
|
+
continue
|
|
1119
|
+
if sub == 'q':
|
|
1120
|
+
break
|
|
1121
|
+
if sub == '0':
|
|
1122
|
+
_ensure_snapshot()
|
|
1123
|
+
_set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
|
|
1124
|
+
elif sub == 's':
|
|
1125
|
+
_ensure_snapshot()
|
|
1126
|
+
_nudge('_bottom_xlabel_manual_offset_y_pts', +1.0)
|
|
1127
|
+
elif sub == 'w':
|
|
1128
|
+
_ensure_snapshot()
|
|
1129
|
+
_nudge('_bottom_xlabel_manual_offset_y_pts', -1.0)
|
|
1130
|
+
else:
|
|
1131
|
+
print("Unknown choice (use s/w/0/q).")
|
|
1132
|
+
continue
|
|
1133
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
1134
|
+
try:
|
|
1135
|
+
fig.canvas.draw_idle()
|
|
1136
|
+
except Exception:
|
|
1137
|
+
pass
|
|
1138
|
+
|
|
1139
|
+
def _left_menu():
|
|
1140
|
+
if not ax.get_ylabel():
|
|
1141
|
+
print("Left title is currently hidden.")
|
|
1142
|
+
return
|
|
1143
|
+
while True:
|
|
1144
|
+
current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
|
|
1145
|
+
print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
|
|
1146
|
+
sub = _safe_input(_colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
|
|
1147
|
+
if not sub:
|
|
1148
|
+
continue
|
|
1149
|
+
if sub == 'q':
|
|
1150
|
+
break
|
|
1151
|
+
if sub == '0':
|
|
1152
|
+
_ensure_snapshot()
|
|
1153
|
+
_set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
|
|
1154
|
+
elif sub == 'a':
|
|
1155
|
+
_ensure_snapshot()
|
|
1156
|
+
_nudge('_left_ylabel_manual_offset_x_pts', +1.0)
|
|
1157
|
+
elif sub == 'd':
|
|
1158
|
+
_ensure_snapshot()
|
|
1159
|
+
_nudge('_left_ylabel_manual_offset_x_pts', -1.0)
|
|
1160
|
+
else:
|
|
1161
|
+
print("Unknown choice (use a/d/0/q).")
|
|
1162
|
+
continue
|
|
1163
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
1164
|
+
try:
|
|
1165
|
+
fig.canvas.draw_idle()
|
|
1166
|
+
except Exception:
|
|
1167
|
+
pass
|
|
1168
|
+
|
|
1169
|
+
while True:
|
|
1170
|
+
print(_colorize_inline_commands("Title offsets:"))
|
|
1171
|
+
print(" " + _colorize_menu('w : adjust top title (w=up, s=down, a=left, d=right)'))
|
|
1172
|
+
print(" " + _colorize_menu('s : adjust bottom title (s=down, w=up)'))
|
|
1173
|
+
print(" " + _colorize_menu('a : adjust left title (a=left, d=right)'))
|
|
1174
|
+
print(" " + _colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
|
|
1175
|
+
print(" " + _colorize_menu('r : reset all offsets'))
|
|
1176
|
+
print(" " + _colorize_menu('q : return'))
|
|
1177
|
+
choice = _safe_input(_colorize_prompt("p> ")).strip().lower()
|
|
1178
|
+
if not choice:
|
|
1179
|
+
continue
|
|
1180
|
+
if choice == 'q':
|
|
1181
|
+
break
|
|
1182
|
+
if choice == 'w':
|
|
1183
|
+
_top_menu()
|
|
1184
|
+
continue
|
|
1185
|
+
if choice == 's':
|
|
1186
|
+
_bottom_menu()
|
|
1187
|
+
continue
|
|
1188
|
+
if choice == 'a':
|
|
1189
|
+
_left_menu()
|
|
1190
|
+
continue
|
|
1191
|
+
if choice == 'd':
|
|
1192
|
+
_right_menu()
|
|
1193
|
+
continue
|
|
1194
|
+
if choice == 'r':
|
|
1195
|
+
_ensure_snapshot()
|
|
1196
|
+
_set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
|
|
1197
|
+
_set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
|
|
1198
|
+
_set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
|
|
1199
|
+
_set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
|
|
1200
|
+
_set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
|
|
1201
|
+
_set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
|
|
1202
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
1203
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
1204
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
1205
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
1206
|
+
try:
|
|
1207
|
+
fig.canvas.draw_idle()
|
|
1208
|
+
except Exception:
|
|
1209
|
+
pass
|
|
1210
|
+
print("Reset manual offsets for all titles.")
|
|
1211
|
+
continue
|
|
1212
|
+
print("Unknown option. Use w/s/a/d/r/q.")
|
|
1213
|
+
|
|
1214
|
+
def _apply_nice_ticks():
|
|
1215
|
+
try:
|
|
1216
|
+
# Only enforce MaxNLocator for linear scales; let Matplotlib defaults handle log/symlog
|
|
1217
|
+
if (getattr(ax, 'get_xscale', None) and ax.get_xscale() == 'linear'):
|
|
1218
|
+
ax.xaxis.set_major_locator(MaxNLocator(nbins='auto', steps=[1, 2, 5], min_n_ticks=4))
|
|
1219
|
+
if (getattr(ax, 'get_yscale', None) and ax.get_yscale() == 'linear'):
|
|
1220
|
+
ax.yaxis.set_major_locator(MaxNLocator(nbins='auto', steps=[1, 2, 5], min_n_ticks=4))
|
|
1221
|
+
except Exception:
|
|
1222
|
+
pass
|
|
1223
|
+
# Ensure nice ticks on entry and apply initial visibility
|
|
1224
|
+
_apply_nice_ticks()
|
|
1225
|
+
_update_tick_visibility()
|
|
1226
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
1227
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
1228
|
+
_store_legend_title(fig, ax)
|
|
1229
|
+
all_cycles = sorted(cycle_lines.keys())
|
|
1230
|
+
|
|
1231
|
+
# Initialize legend visibility preference
|
|
1232
|
+
if not hasattr(fig, '_ec_legend_user_visible'):
|
|
1233
|
+
try:
|
|
1234
|
+
leg0 = ax.get_legend()
|
|
1235
|
+
visible = True
|
|
1236
|
+
if leg0 is not None:
|
|
1237
|
+
visible = bool(leg0.get_visible())
|
|
1238
|
+
_set_legend_user_pref(fig, visible)
|
|
1239
|
+
except Exception:
|
|
1240
|
+
_set_legend_user_pref(fig, True)
|
|
1241
|
+
else:
|
|
1242
|
+
if not _get_legend_user_pref(fig):
|
|
1243
|
+
leg0 = ax.get_legend()
|
|
1244
|
+
if leg0 is not None:
|
|
1245
|
+
try:
|
|
1246
|
+
leg0.set_visible(False)
|
|
1247
|
+
except Exception:
|
|
1248
|
+
pass
|
|
1249
|
+
# ---------------- Undo stack ----------------
|
|
1250
|
+
state_history: List[dict] = []
|
|
1251
|
+
|
|
1252
|
+
def _tick_width(axis_obj, which: str):
|
|
1253
|
+
try:
|
|
1254
|
+
tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
|
|
1255
|
+
width = tick_kw.get('width')
|
|
1256
|
+
if width is None:
|
|
1257
|
+
axis_name = getattr(axis_obj, 'axis_name', 'x')
|
|
1258
|
+
rc_key = f"{axis_name}tick.{which}.width"
|
|
1259
|
+
width = plt.rcParams.get(rc_key)
|
|
1260
|
+
if width is not None:
|
|
1261
|
+
return float(width)
|
|
1262
|
+
except Exception:
|
|
1263
|
+
return None
|
|
1264
|
+
return None
|
|
1265
|
+
|
|
1266
|
+
def push_state(note: str = ""):
|
|
1267
|
+
try:
|
|
1268
|
+
snap = {
|
|
1269
|
+
'note': note,
|
|
1270
|
+
'xlim': ax.get_xlim(),
|
|
1271
|
+
'ylim': ax.get_ylim(),
|
|
1272
|
+
'xscale': ax.get_xscale(),
|
|
1273
|
+
'yscale': ax.get_yscale(),
|
|
1274
|
+
'xlabel': ax.get_xlabel(),
|
|
1275
|
+
'ylabel': ax.get_ylabel(),
|
|
1276
|
+
'tick_state': dict(tick_state),
|
|
1277
|
+
'wasd_state': dict(getattr(fig, '_ec_wasd_state', {})) if hasattr(fig, '_ec_wasd_state') else {},
|
|
1278
|
+
'fig_size': list(fig.get_size_inches()),
|
|
1279
|
+
'rotation_angle': getattr(fig, '_ec_rotation_angle', 0),
|
|
1280
|
+
'labelpads': {
|
|
1281
|
+
'x': getattr(ax.xaxis, 'labelpad', None),
|
|
1282
|
+
'y': getattr(ax.yaxis, 'labelpad', None),
|
|
1283
|
+
},
|
|
1284
|
+
'spines': {name: {
|
|
1285
|
+
'lw': (ax.spines.get(name).get_linewidth() if ax.spines.get(name) else None),
|
|
1286
|
+
'visible': (ax.spines.get(name).get_visible() if ax.spines.get(name) else None),
|
|
1287
|
+
'color': (ax.spines.get(name).get_edgecolor() if ax.spines.get(name) else None)
|
|
1288
|
+
} for name in ('bottom','top','left','right')},
|
|
1289
|
+
'tick_widths': {
|
|
1290
|
+
'x_major': _tick_width(ax.xaxis, 'major'),
|
|
1291
|
+
'x_minor': _tick_width(ax.xaxis, 'minor'),
|
|
1292
|
+
'y_major': _tick_width(ax.yaxis, 'major'),
|
|
1293
|
+
'y_minor': _tick_width(ax.yaxis, 'minor')
|
|
1294
|
+
},
|
|
1295
|
+
'tick_lengths': dict(getattr(fig, '_tick_lengths', {'major': None, 'minor': None})),
|
|
1296
|
+
'tick_direction': getattr(fig, '_tick_direction', 'out'),
|
|
1297
|
+
'font_size': plt.rcParams.get('font.size'),
|
|
1298
|
+
'font_family': plt.rcParams.get('font.family'),
|
|
1299
|
+
'font_sans_serif': list(plt.rcParams.get('font.sans-serif', [])),
|
|
1300
|
+
'titles': {
|
|
1301
|
+
'top_x': bool(getattr(ax, '_top_xlabel_on', False)),
|
|
1302
|
+
'right_y': bool(getattr(ax, '_right_ylabel_on', False))
|
|
1303
|
+
},
|
|
1304
|
+
'title_offsets': {
|
|
1305
|
+
'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1306
|
+
'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1307
|
+
'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1308
|
+
'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1309
|
+
'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1310
|
+
'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1311
|
+
},
|
|
1312
|
+
'legend': {
|
|
1313
|
+
'visible': False,
|
|
1314
|
+
'position_inches': None,
|
|
1315
|
+
},
|
|
1316
|
+
'grid': False,
|
|
1317
|
+
'lines': []
|
|
1318
|
+
}
|
|
1319
|
+
# Grid state
|
|
1320
|
+
try:
|
|
1321
|
+
current_grid = False
|
|
1322
|
+
for line in ax.get_xgridlines() + ax.get_ygridlines():
|
|
1323
|
+
if line.get_visible():
|
|
1324
|
+
current_grid = True
|
|
1325
|
+
break
|
|
1326
|
+
snap['grid'] = current_grid
|
|
1327
|
+
except Exception:
|
|
1328
|
+
snap['grid'] = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
|
|
1329
|
+
try:
|
|
1330
|
+
leg_obj = ax.get_legend()
|
|
1331
|
+
snap['legend']['visible'] = bool(leg_obj.get_visible()) if leg_obj is not None else False
|
|
1332
|
+
except Exception:
|
|
1333
|
+
pass
|
|
1334
|
+
try:
|
|
1335
|
+
snap['legend']['title'] = _get_legend_title(fig)
|
|
1336
|
+
except Exception:
|
|
1337
|
+
snap['legend']['title'] = None
|
|
1338
|
+
try:
|
|
1339
|
+
legend_xy = getattr(fig, '_ec_legend_xy_in', None)
|
|
1340
|
+
if legend_xy is not None:
|
|
1341
|
+
snap['legend']['position_inches'] = (float(legend_xy[0]), float(legend_xy[1]))
|
|
1342
|
+
except Exception:
|
|
1343
|
+
snap['legend']['position_inches'] = None
|
|
1344
|
+
for i, ln in enumerate(ax.lines):
|
|
1345
|
+
try:
|
|
1346
|
+
snap['lines'].append({
|
|
1347
|
+
'index': i,
|
|
1348
|
+
'x': np.array(ln.get_xdata(), copy=True),
|
|
1349
|
+
'y': np.array(ln.get_ydata(), copy=True),
|
|
1350
|
+
'color': ln.get_color(),
|
|
1351
|
+
'lw': ln.get_linewidth(),
|
|
1352
|
+
'ls': ln.get_linestyle(),
|
|
1353
|
+
'alpha': ln.get_alpha(),
|
|
1354
|
+
'visible': ln.get_visible(),
|
|
1355
|
+
'marker': ln.get_marker(),
|
|
1356
|
+
'markersize': getattr(ln, 'get_markersize', lambda: None)(),
|
|
1357
|
+
'markerfacecolor': getattr(ln, 'get_markerfacecolor', lambda: None)(),
|
|
1358
|
+
'markeredgecolor': getattr(ln, 'get_markeredgecolor', lambda: None)()
|
|
1359
|
+
})
|
|
1360
|
+
except Exception:
|
|
1361
|
+
snap['lines'].append({'index': i})
|
|
1362
|
+
state_history.append(snap)
|
|
1363
|
+
if len(state_history) > 40:
|
|
1364
|
+
state_history.pop(0)
|
|
1365
|
+
except Exception:
|
|
1366
|
+
# Minimal fallback so undo still works if full snapshot fails
|
|
1367
|
+
try:
|
|
1368
|
+
fallback = {
|
|
1369
|
+
'note': f"{note}-fallback",
|
|
1370
|
+
'xlim': ax.get_xlim(),
|
|
1371
|
+
'ylim': ax.get_ylim(),
|
|
1372
|
+
'legend': {
|
|
1373
|
+
'visible': bool(ax.get_legend().get_visible()) if ax.get_legend() else False,
|
|
1374
|
+
'position_inches': getattr(fig, '_ec_legend_xy_in', None),
|
|
1375
|
+
'title': _get_legend_title(fig),
|
|
1376
|
+
},
|
|
1377
|
+
'lines': []
|
|
1378
|
+
}
|
|
1379
|
+
for i, ln in enumerate(ax.lines):
|
|
1380
|
+
try:
|
|
1381
|
+
fallback['lines'].append({
|
|
1382
|
+
'index': i,
|
|
1383
|
+
'color': ln.get_color(),
|
|
1384
|
+
'visible': ln.get_visible(),
|
|
1385
|
+
})
|
|
1386
|
+
except Exception:
|
|
1387
|
+
fallback['lines'].append({'index': i})
|
|
1388
|
+
state_history.append(fallback)
|
|
1389
|
+
if len(state_history) > 40:
|
|
1390
|
+
state_history.pop(0)
|
|
1391
|
+
except Exception:
|
|
1392
|
+
pass
|
|
1393
|
+
|
|
1394
|
+
def restore_state():
|
|
1395
|
+
if not state_history:
|
|
1396
|
+
print("No undo history.")
|
|
1397
|
+
return
|
|
1398
|
+
snap = state_history.pop()
|
|
1399
|
+
try:
|
|
1400
|
+
# Scales, limits, labels
|
|
1401
|
+
try:
|
|
1402
|
+
ax.set_xscale(snap.get('xscale','linear'))
|
|
1403
|
+
ax.set_yscale(snap.get('yscale','linear'))
|
|
1404
|
+
except Exception:
|
|
1405
|
+
pass
|
|
1406
|
+
try:
|
|
1407
|
+
ax.set_xlim(*snap.get('xlim', ax.get_xlim()))
|
|
1408
|
+
ax.set_ylim(*snap.get('ylim', ax.get_ylim()))
|
|
1409
|
+
_apply_nice_ticks()
|
|
1410
|
+
except Exception:
|
|
1411
|
+
pass
|
|
1412
|
+
try:
|
|
1413
|
+
ax.set_xlabel(snap.get('xlabel') or '')
|
|
1414
|
+
ax.set_ylabel(snap.get('ylabel') or '')
|
|
1415
|
+
except Exception:
|
|
1416
|
+
pass
|
|
1417
|
+
# Tick state
|
|
1418
|
+
st = snap.get('tick_state', {})
|
|
1419
|
+
for k,v in st.items():
|
|
1420
|
+
if k in tick_state:
|
|
1421
|
+
tick_state[k] = bool(v)
|
|
1422
|
+
# WASD state
|
|
1423
|
+
wasd_snap = snap.get('wasd_state', {})
|
|
1424
|
+
if wasd_snap:
|
|
1425
|
+
setattr(fig, '_ec_wasd_state', wasd_snap)
|
|
1426
|
+
_sync_tick_state()
|
|
1427
|
+
_apply_wasd()
|
|
1428
|
+
_update_tick_visibility()
|
|
1429
|
+
# Rotation angle
|
|
1430
|
+
try:
|
|
1431
|
+
rot_angle = snap.get('rotation_angle', 0)
|
|
1432
|
+
setattr(fig, '_ec_rotation_angle', rot_angle)
|
|
1433
|
+
except Exception:
|
|
1434
|
+
pass
|
|
1435
|
+
# Spines
|
|
1436
|
+
for name, spec in snap.get('spines', {}).items():
|
|
1437
|
+
sp = ax.spines.get(name)
|
|
1438
|
+
if not sp: continue
|
|
1439
|
+
if spec.get('lw') is not None:
|
|
1440
|
+
try: sp.set_linewidth(spec['lw'])
|
|
1441
|
+
except Exception: pass
|
|
1442
|
+
if spec.get('visible') is not None:
|
|
1443
|
+
try: sp.set_visible(bool(spec['visible']))
|
|
1444
|
+
except Exception: pass
|
|
1445
|
+
if spec.get('color') is not None:
|
|
1446
|
+
try:
|
|
1447
|
+
sp.set_edgecolor(spec['color'])
|
|
1448
|
+
if name in ('top', 'bottom'):
|
|
1449
|
+
ax.tick_params(axis='x', which='both', colors=spec['color'])
|
|
1450
|
+
ax.xaxis.label.set_color(spec['color'])
|
|
1451
|
+
else:
|
|
1452
|
+
ax.tick_params(axis='y', which='both', colors=spec['color'])
|
|
1453
|
+
ax.yaxis.label.set_color(spec['color'])
|
|
1454
|
+
except Exception:
|
|
1455
|
+
pass
|
|
1456
|
+
# Tick widths
|
|
1457
|
+
tw = snap.get('tick_widths', {})
|
|
1458
|
+
try:
|
|
1459
|
+
if tw.get('x_major') is not None:
|
|
1460
|
+
ax.tick_params(axis='x', which='major', width=tw['x_major'])
|
|
1461
|
+
if tw.get('x_minor') is not None:
|
|
1462
|
+
ax.tick_params(axis='x', which='minor', width=tw['x_minor'])
|
|
1463
|
+
if tw.get('y_major') is not None:
|
|
1464
|
+
ax.tick_params(axis='y', which='major', width=tw['y_major'])
|
|
1465
|
+
if tw.get('y_minor') is not None:
|
|
1466
|
+
ax.tick_params(axis='y', which='minor', width=tw['y_minor'])
|
|
1467
|
+
except Exception:
|
|
1468
|
+
pass
|
|
1469
|
+
# Tick lengths
|
|
1470
|
+
tl = snap.get('tick_lengths', {})
|
|
1471
|
+
try:
|
|
1472
|
+
if tl.get('major') is not None:
|
|
1473
|
+
ax.tick_params(axis='both', which='major', length=tl['major'])
|
|
1474
|
+
if tl.get('minor') is not None:
|
|
1475
|
+
ax.tick_params(axis='both', which='minor', length=tl['minor'])
|
|
1476
|
+
if tl:
|
|
1477
|
+
fig._tick_lengths = dict(tl)
|
|
1478
|
+
except Exception:
|
|
1479
|
+
pass
|
|
1480
|
+
# Tick direction
|
|
1481
|
+
try:
|
|
1482
|
+
tick_dir = snap.get('tick_direction', 'out')
|
|
1483
|
+
if tick_dir:
|
|
1484
|
+
setattr(fig, '_tick_direction', tick_dir)
|
|
1485
|
+
ax.tick_params(axis='both', which='both', direction=tick_dir)
|
|
1486
|
+
except Exception:
|
|
1487
|
+
pass
|
|
1488
|
+
# Font size and family
|
|
1489
|
+
try:
|
|
1490
|
+
import matplotlib as mpl
|
|
1491
|
+
font_size = snap.get('font_size')
|
|
1492
|
+
if font_size is not None:
|
|
1493
|
+
mpl.rcParams['font.size'] = font_size
|
|
1494
|
+
_apply_font_size(ax, font_size)
|
|
1495
|
+
_rebuild_legend(ax)
|
|
1496
|
+
except Exception:
|
|
1497
|
+
pass
|
|
1498
|
+
try:
|
|
1499
|
+
import matplotlib as mpl
|
|
1500
|
+
font_family = snap.get('font_family')
|
|
1501
|
+
font_sans_serif = snap.get('font_sans_serif')
|
|
1502
|
+
if font_family is not None:
|
|
1503
|
+
mpl.rcParams['font.family'] = font_family
|
|
1504
|
+
if font_sans_serif is not None:
|
|
1505
|
+
mpl.rcParams['font.sans-serif'] = font_sans_serif
|
|
1506
|
+
# Apply to axes if family was set
|
|
1507
|
+
if font_family or font_sans_serif:
|
|
1508
|
+
# Get the actual font family to use
|
|
1509
|
+
if font_sans_serif and len(font_sans_serif) > 0:
|
|
1510
|
+
_apply_font_family(ax, font_sans_serif[0])
|
|
1511
|
+
elif font_family:
|
|
1512
|
+
_apply_font_family(ax, font_family)
|
|
1513
|
+
_rebuild_legend(ax)
|
|
1514
|
+
except Exception:
|
|
1515
|
+
pass
|
|
1516
|
+
# Title offsets - all four titles
|
|
1517
|
+
try:
|
|
1518
|
+
offsets = snap.get('title_offsets', {})
|
|
1519
|
+
# Support both old format (top/right) and new format (top_y/top_x/bottom_y/left_x/right_x/right_y)
|
|
1520
|
+
try:
|
|
1521
|
+
if 'top_y' in offsets:
|
|
1522
|
+
ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top_y', 0.0) or 0.0)
|
|
1523
|
+
else:
|
|
1524
|
+
# Backward compatibility: old format used 'top' for y-offset
|
|
1525
|
+
ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top', 0.0) or 0.0)
|
|
1526
|
+
except Exception:
|
|
1527
|
+
ax._top_xlabel_manual_offset_y_pts = 0.0
|
|
1528
|
+
try:
|
|
1529
|
+
ax._top_xlabel_manual_offset_x_pts = float(offsets.get('top_x', 0.0) or 0.0)
|
|
1530
|
+
except Exception:
|
|
1531
|
+
ax._top_xlabel_manual_offset_x_pts = 0.0
|
|
1532
|
+
try:
|
|
1533
|
+
ax._bottom_xlabel_manual_offset_y_pts = float(offsets.get('bottom_y', 0.0) or 0.0)
|
|
1534
|
+
except Exception:
|
|
1535
|
+
ax._bottom_xlabel_manual_offset_y_pts = 0.0
|
|
1536
|
+
try:
|
|
1537
|
+
ax._left_ylabel_manual_offset_x_pts = float(offsets.get('left_x', 0.0) or 0.0)
|
|
1538
|
+
except Exception:
|
|
1539
|
+
ax._left_ylabel_manual_offset_x_pts = 0.0
|
|
1540
|
+
try:
|
|
1541
|
+
if 'right_x' in offsets:
|
|
1542
|
+
ax._right_ylabel_manual_offset_x_pts = float(offsets.get('right_x', 0.0) or 0.0)
|
|
1543
|
+
else:
|
|
1544
|
+
# Backward compatibility: old format used 'right' for x-offset
|
|
1545
|
+
ax._right_ylabel_manual_offset_x_pts = float(offsets.get('right', 0.0) or 0.0)
|
|
1546
|
+
except Exception:
|
|
1547
|
+
ax._right_ylabel_manual_offset_x_pts = 0.0
|
|
1548
|
+
try:
|
|
1549
|
+
ax._right_ylabel_manual_offset_y_pts = float(offsets.get('right_y', 0.0) or 0.0)
|
|
1550
|
+
except Exception:
|
|
1551
|
+
ax._right_ylabel_manual_offset_y_pts = 0.0
|
|
1552
|
+
ax._top_xlabel_on = bool(snap.get('titles',{}).get('top_x', False))
|
|
1553
|
+
ax._right_ylabel_on = bool(snap.get('titles',{}).get('right_y', False))
|
|
1554
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
1555
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
1556
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
1557
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
1558
|
+
except Exception:
|
|
1559
|
+
pass
|
|
1560
|
+
# Restore labelpads (for title positioning)
|
|
1561
|
+
try:
|
|
1562
|
+
pads = snap.get('labelpads', {})
|
|
1563
|
+
if pads:
|
|
1564
|
+
if pads.get('x') is not None:
|
|
1565
|
+
ax.xaxis.labelpad = pads['x']
|
|
1566
|
+
if pads.get('y') is not None:
|
|
1567
|
+
ax.yaxis.labelpad = pads['y']
|
|
1568
|
+
except Exception:
|
|
1569
|
+
pass
|
|
1570
|
+
# Lines (by index)
|
|
1571
|
+
try:
|
|
1572
|
+
if len(ax.lines) == len(snap.get('lines', [])):
|
|
1573
|
+
for item in snap['lines']:
|
|
1574
|
+
idx = item.get('index')
|
|
1575
|
+
if idx is None or idx >= len(ax.lines):
|
|
1576
|
+
continue
|
|
1577
|
+
ln = ax.lines[idx]
|
|
1578
|
+
if 'x' in item and 'y' in item:
|
|
1579
|
+
ln.set_data(item['x'], item['y'])
|
|
1580
|
+
if item.get('color') is not None:
|
|
1581
|
+
ln.set_color(item['color'])
|
|
1582
|
+
if item.get('lw') is not None:
|
|
1583
|
+
ln.set_linewidth(item['lw'])
|
|
1584
|
+
if item.get('ls') is not None:
|
|
1585
|
+
ln.set_linestyle(item['ls'])
|
|
1586
|
+
if item.get('alpha') is not None:
|
|
1587
|
+
ln.set_alpha(item['alpha'])
|
|
1588
|
+
if item.get('visible') is not None:
|
|
1589
|
+
ln.set_visible(bool(item['visible']))
|
|
1590
|
+
if item.get('marker') is not None:
|
|
1591
|
+
ln.set_marker(item['marker'])
|
|
1592
|
+
if item.get('markersize') is not None:
|
|
1593
|
+
try:
|
|
1594
|
+
ln.set_markersize(item['markersize'])
|
|
1595
|
+
except Exception:
|
|
1596
|
+
pass
|
|
1597
|
+
if item.get('markerfacecolor') is not None:
|
|
1598
|
+
try:
|
|
1599
|
+
ln.set_markerfacecolor(item['markerfacecolor'])
|
|
1600
|
+
except Exception:
|
|
1601
|
+
pass
|
|
1602
|
+
if item.get('markeredgecolor') is not None:
|
|
1603
|
+
try:
|
|
1604
|
+
ln.set_markeredgecolor(item['markeredgecolor'])
|
|
1605
|
+
except Exception:
|
|
1606
|
+
pass
|
|
1607
|
+
except Exception:
|
|
1608
|
+
pass
|
|
1609
|
+
# Grid state
|
|
1610
|
+
if 'grid' in snap:
|
|
1611
|
+
try:
|
|
1612
|
+
grid_enabled = snap.get('grid', False)
|
|
1613
|
+
if grid_enabled:
|
|
1614
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
1615
|
+
else:
|
|
1616
|
+
ax.grid(False)
|
|
1617
|
+
except Exception:
|
|
1618
|
+
pass
|
|
1619
|
+
legend_snap = snap.get('legend', {})
|
|
1620
|
+
if legend_snap:
|
|
1621
|
+
try:
|
|
1622
|
+
if 'title' in legend_snap:
|
|
1623
|
+
fig._ec_legend_title = legend_snap.get('title') or _get_legend_title(fig)
|
|
1624
|
+
xy = legend_snap.get('position_inches')
|
|
1625
|
+
fig._ec_legend_xy_in = _sanitize_legend_offset(fig, xy) if xy is not None else None
|
|
1626
|
+
except Exception:
|
|
1627
|
+
pass
|
|
1628
|
+
_rebuild_legend(ax)
|
|
1629
|
+
if legend_snap:
|
|
1630
|
+
try:
|
|
1631
|
+
if legend_snap.get('visible'):
|
|
1632
|
+
_apply_legend_position(fig, ax)
|
|
1633
|
+
leg_obj = ax.get_legend()
|
|
1634
|
+
if leg_obj is not None:
|
|
1635
|
+
leg_obj.set_visible(bool(legend_snap.get('visible', False)))
|
|
1636
|
+
except Exception:
|
|
1637
|
+
pass
|
|
1638
|
+
try:
|
|
1639
|
+
fig.canvas.draw()
|
|
1640
|
+
except Exception:
|
|
1641
|
+
fig.canvas.draw_idle()
|
|
1642
|
+
print("Undo: restored previous state.")
|
|
1643
|
+
except Exception as e:
|
|
1644
|
+
print(f"Undo failed: {e}")
|
|
1645
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1646
|
+
while True:
|
|
1647
|
+
try:
|
|
1648
|
+
key = _safe_input("Press a key: ").strip().lower()
|
|
1649
|
+
except (KeyboardInterrupt, EOFError):
|
|
1650
|
+
print("\n\nExiting interactive menu...")
|
|
1651
|
+
break
|
|
1652
|
+
if not key:
|
|
1653
|
+
continue
|
|
1654
|
+
if key == 'q':
|
|
1655
|
+
try:
|
|
1656
|
+
confirm = _safe_input(_colorize_prompt("Quit EC interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
|
|
1657
|
+
except Exception:
|
|
1658
|
+
confirm = 'y'
|
|
1659
|
+
if confirm == 'y':
|
|
1660
|
+
break
|
|
1661
|
+
else:
|
|
1662
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1663
|
+
continue
|
|
1664
|
+
elif key == 'b':
|
|
1665
|
+
restore_state()
|
|
1666
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1667
|
+
continue
|
|
1668
|
+
elif key == 'e':
|
|
1669
|
+
# Export current figure to a file; default extension .svg if missing
|
|
1670
|
+
try:
|
|
1671
|
+
base_path = choose_save_path(source_paths, purpose="figure export")
|
|
1672
|
+
if not base_path:
|
|
1673
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1674
|
+
continue
|
|
1675
|
+
# List existing figure files in Figures/ subdirectory
|
|
1676
|
+
fig_extensions = ('.svg', '.png', '.jpg', '.jpeg', '.pdf', '.eps', '.tif', '.tiff')
|
|
1677
|
+
file_list = list_files_in_subdirectory(fig_extensions, 'figure', base_path=base_path)
|
|
1678
|
+
files = [f[0] for f in file_list]
|
|
1679
|
+
if files:
|
|
1680
|
+
figures_dir = os.path.join(base_path, 'Figures')
|
|
1681
|
+
print(f"Existing figure files in {figures_dir}:")
|
|
1682
|
+
for i, (fname, fpath) in enumerate(file_list, 1):
|
|
1683
|
+
timestamp = _format_file_timestamp(fpath)
|
|
1684
|
+
if timestamp:
|
|
1685
|
+
print(f" {i}: {fname} ({timestamp})")
|
|
1686
|
+
else:
|
|
1687
|
+
print(f" {i}: {fname}")
|
|
1688
|
+
|
|
1689
|
+
last_figure_path = getattr(fig, '_last_figure_export_path', None)
|
|
1690
|
+
if last_figure_path:
|
|
1691
|
+
fname = _safe_input("Export filename (default .svg if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
|
|
1692
|
+
else:
|
|
1693
|
+
fname = _safe_input("Export filename (default .svg if no extension) or number to overwrite (q=cancel): ").strip()
|
|
1694
|
+
if not fname or fname.lower() == 'q':
|
|
1695
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1696
|
+
continue
|
|
1697
|
+
|
|
1698
|
+
already_confirmed = False # Initialize for new filename case
|
|
1699
|
+
# Check for 'o' option
|
|
1700
|
+
if fname.lower() == 'o':
|
|
1701
|
+
if not last_figure_path:
|
|
1702
|
+
print("No previous export found.")
|
|
1703
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1704
|
+
continue
|
|
1705
|
+
if not os.path.exists(last_figure_path):
|
|
1706
|
+
print(f"Previous export file not found: {last_figure_path}")
|
|
1707
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1708
|
+
continue
|
|
1709
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
|
|
1710
|
+
if yn != 'y':
|
|
1711
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1712
|
+
continue
|
|
1713
|
+
target = last_figure_path
|
|
1714
|
+
already_confirmed = True
|
|
1715
|
+
# Check if user selected a number
|
|
1716
|
+
elif fname.isdigit() and files:
|
|
1717
|
+
already_confirmed = False
|
|
1718
|
+
idx = int(fname)
|
|
1719
|
+
if 1 <= idx <= len(files):
|
|
1720
|
+
name = files[idx-1]
|
|
1721
|
+
yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
|
|
1722
|
+
if yn != 'y':
|
|
1723
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1724
|
+
continue
|
|
1725
|
+
target = file_list[idx-1][1] # Full path from list
|
|
1726
|
+
already_confirmed = True
|
|
1727
|
+
else:
|
|
1728
|
+
print("Invalid number.")
|
|
1729
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1730
|
+
continue
|
|
1731
|
+
else:
|
|
1732
|
+
root, ext = os.path.splitext(fname)
|
|
1733
|
+
if ext == '':
|
|
1734
|
+
fname = fname + '.svg'
|
|
1735
|
+
# Use organized path unless it's an absolute path
|
|
1736
|
+
if os.path.isabs(fname):
|
|
1737
|
+
target = fname
|
|
1738
|
+
else:
|
|
1739
|
+
target = get_organized_path(fname, 'figure', base_path=base_path)
|
|
1740
|
+
|
|
1741
|
+
try:
|
|
1742
|
+
if not already_confirmed and os.path.exists(target):
|
|
1743
|
+
target = _confirm_overwrite(target)
|
|
1744
|
+
if target:
|
|
1745
|
+
# Ensure exact case is preserved (important for macOS case-insensitive filesystem)
|
|
1746
|
+
from .utils import ensure_exact_case_filename
|
|
1747
|
+
target = ensure_exact_case_filename(target)
|
|
1748
|
+
|
|
1749
|
+
# Save current legend position before export (savefig can change layout)
|
|
1750
|
+
saved_legend_pos = None
|
|
1751
|
+
try:
|
|
1752
|
+
saved_legend_pos = getattr(fig, '_ec_legend_xy_in', None)
|
|
1753
|
+
except Exception:
|
|
1754
|
+
pass
|
|
1755
|
+
|
|
1756
|
+
# If exporting SVG, make background transparent for PowerPoint
|
|
1757
|
+
_, ext2 = os.path.splitext(target)
|
|
1758
|
+
ext2 = ext2.lower()
|
|
1759
|
+
if ext2 == '.svg':
|
|
1760
|
+
# Save original patch states
|
|
1761
|
+
try:
|
|
1762
|
+
fig_fc = fig.get_facecolor()
|
|
1763
|
+
except Exception:
|
|
1764
|
+
fig_fc = None
|
|
1765
|
+
try:
|
|
1766
|
+
ax_fc = ax.get_facecolor()
|
|
1767
|
+
except Exception:
|
|
1768
|
+
ax_fc = None
|
|
1769
|
+
try:
|
|
1770
|
+
# Set transparent patches
|
|
1771
|
+
if getattr(fig, 'patch', None) is not None:
|
|
1772
|
+
fig.patch.set_alpha(0.0)
|
|
1773
|
+
fig.patch.set_facecolor('none')
|
|
1774
|
+
if getattr(ax, 'patch', None) is not None:
|
|
1775
|
+
ax.patch.set_alpha(0.0)
|
|
1776
|
+
ax.patch.set_facecolor('none')
|
|
1777
|
+
except Exception:
|
|
1778
|
+
pass
|
|
1779
|
+
try:
|
|
1780
|
+
fig.savefig(target, bbox_inches='tight', transparent=True, facecolor='none', edgecolor='none')
|
|
1781
|
+
finally:
|
|
1782
|
+
# Restore original patches if available
|
|
1783
|
+
try:
|
|
1784
|
+
if fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
1785
|
+
fig.patch.set_alpha(1.0)
|
|
1786
|
+
fig.patch.set_facecolor(fig_fc)
|
|
1787
|
+
except Exception:
|
|
1788
|
+
pass
|
|
1789
|
+
try:
|
|
1790
|
+
if ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
1791
|
+
ax.patch.set_alpha(1.0)
|
|
1792
|
+
ax.patch.set_facecolor(ax_fc)
|
|
1793
|
+
except Exception:
|
|
1794
|
+
pass
|
|
1795
|
+
else:
|
|
1796
|
+
fig.savefig(target, bbox_inches='tight')
|
|
1797
|
+
print(f"Exported figure to {target}")
|
|
1798
|
+
fig._last_figure_export_path = target
|
|
1799
|
+
|
|
1800
|
+
# Restore legend position after savefig (which may have changed layout)
|
|
1801
|
+
if saved_legend_pos is not None:
|
|
1802
|
+
try:
|
|
1803
|
+
fig._ec_legend_xy_in = saved_legend_pos
|
|
1804
|
+
_rebuild_legend(ax)
|
|
1805
|
+
fig.canvas.draw_idle()
|
|
1806
|
+
except Exception:
|
|
1807
|
+
pass
|
|
1808
|
+
except Exception as e:
|
|
1809
|
+
print(f"Export failed: {e}")
|
|
1810
|
+
except Exception as e:
|
|
1811
|
+
print(f"Export failed: {e}")
|
|
1812
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1813
|
+
continue
|
|
1814
|
+
elif key == 'h':
|
|
1815
|
+
# Legend submenu: toggle visibility and move legend in inches relative to canvas center
|
|
1816
|
+
try:
|
|
1817
|
+
fig = ax.figure
|
|
1818
|
+
# Ensure resize hook to reapply custom position
|
|
1819
|
+
if not hasattr(fig, '_ec_legpos_cid') or getattr(fig, '_ec_legpos_cid') is None:
|
|
1820
|
+
def _on_resize_ec(event):
|
|
1821
|
+
try:
|
|
1822
|
+
leg = ax.get_legend()
|
|
1823
|
+
if leg is None or not leg.get_visible():
|
|
1824
|
+
return
|
|
1825
|
+
if _apply_legend_position(fig, ax):
|
|
1826
|
+
fig.canvas.draw_idle()
|
|
1827
|
+
except Exception:
|
|
1828
|
+
pass
|
|
1829
|
+
fig._ec_legpos_cid = fig.canvas.mpl_connect('resize_event', _on_resize_ec)
|
|
1830
|
+
# If we don't yet have a stored inches position, derive it from current legend
|
|
1831
|
+
try:
|
|
1832
|
+
if not hasattr(fig, '_ec_legend_xy_in') or getattr(fig, '_ec_legend_xy_in') is None:
|
|
1833
|
+
leg0 = ax.get_legend()
|
|
1834
|
+
if leg0 is not None:
|
|
1835
|
+
try:
|
|
1836
|
+
try:
|
|
1837
|
+
renderer = fig.canvas.get_renderer()
|
|
1838
|
+
except Exception:
|
|
1839
|
+
fig.canvas.draw()
|
|
1840
|
+
renderer = fig.canvas.get_renderer()
|
|
1841
|
+
bb = leg0.get_window_extent(renderer=renderer)
|
|
1842
|
+
cx = 0.5 * (bb.x0 + bb.x1)
|
|
1843
|
+
cy = 0.5 * (bb.y0 + bb.y1)
|
|
1844
|
+
fx, fy = fig.transFigure.inverted().transform((cx, cy))
|
|
1845
|
+
fw, fh = fig.get_size_inches()
|
|
1846
|
+
offset = _sanitize_legend_offset(fig, ((fx - 0.5) * fw, (fy - 0.5) * fh))
|
|
1847
|
+
if offset is not None:
|
|
1848
|
+
fig._ec_legend_xy_in = offset
|
|
1849
|
+
except Exception:
|
|
1850
|
+
pass
|
|
1851
|
+
except Exception:
|
|
1852
|
+
pass
|
|
1853
|
+
# Current status
|
|
1854
|
+
leg = ax.get_legend()
|
|
1855
|
+
vis = bool(leg.get_visible()) if leg is not None else False
|
|
1856
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
|
|
1857
|
+
print(f"Legend is {'ON' if vis else 'off'}; position (inches from center): x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
|
|
1858
|
+
while True:
|
|
1859
|
+
sub = _safe_input(_colorize_prompt("Legend: (t=toggle, p=set position, q=back): ")).strip().lower()
|
|
1860
|
+
if not sub:
|
|
1861
|
+
continue
|
|
1862
|
+
if sub == 'q':
|
|
1863
|
+
break
|
|
1864
|
+
if sub == 't':
|
|
1865
|
+
push_state("legend-toggle")
|
|
1866
|
+
try:
|
|
1867
|
+
leg = ax.get_legend()
|
|
1868
|
+
if leg is not None and leg.get_visible():
|
|
1869
|
+
leg.set_visible(False)
|
|
1870
|
+
_set_legend_user_pref(fig, False)
|
|
1871
|
+
_rebuild_legend(ax)
|
|
1872
|
+
else:
|
|
1873
|
+
_set_legend_user_pref(fig, True)
|
|
1874
|
+
_rebuild_legend(ax)
|
|
1875
|
+
fig.canvas.draw_idle()
|
|
1876
|
+
except Exception:
|
|
1877
|
+
pass
|
|
1878
|
+
elif sub == 'p':
|
|
1879
|
+
# Position submenu with x and y subcommands
|
|
1880
|
+
while True:
|
|
1881
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
|
|
1882
|
+
print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
|
|
1883
|
+
pos_cmd = _safe_input(_colorize_prompt("Position: (x y) or x=x only, y=y only, q=back: ")).strip().lower()
|
|
1884
|
+
if not pos_cmd or pos_cmd == 'q':
|
|
1885
|
+
break
|
|
1886
|
+
if pos_cmd == 'x':
|
|
1887
|
+
# X only: stay in loop
|
|
1888
|
+
while True:
|
|
1889
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
|
|
1890
|
+
print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
|
|
1891
|
+
val = _safe_input(f"Enter new x position (current y: {xy_in[1]:.2f}, q=back): ").strip()
|
|
1892
|
+
if not val or val.lower() == 'q':
|
|
1893
|
+
break
|
|
1894
|
+
try:
|
|
1895
|
+
x_in = float(val)
|
|
1896
|
+
except (ValueError, KeyboardInterrupt):
|
|
1897
|
+
print("Invalid number, ignored.")
|
|
1898
|
+
continue
|
|
1899
|
+
push_state("legend-position")
|
|
1900
|
+
try:
|
|
1901
|
+
fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (x_in, xy_in[1]))
|
|
1902
|
+
# If legend visible, reposition now
|
|
1903
|
+
leg = ax.get_legend()
|
|
1904
|
+
if leg is not None and leg.get_visible():
|
|
1905
|
+
if not _apply_legend_position(fig, ax):
|
|
1906
|
+
handles, labels = _visible_legend_entries(ax)
|
|
1907
|
+
if handles:
|
|
1908
|
+
_legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
|
|
1909
|
+
fig.canvas.draw_idle()
|
|
1910
|
+
print(f"Legend position updated: x={x_in:.2f}, y={xy_in[1]:.2f}")
|
|
1911
|
+
except Exception:
|
|
1912
|
+
pass
|
|
1913
|
+
elif pos_cmd == 'y':
|
|
1914
|
+
# Y only: stay in loop
|
|
1915
|
+
while True:
|
|
1916
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', (0.0, 0.0))) or (0.0, 0.0)
|
|
1917
|
+
print(f"Current position: x={xy_in[0]:.2f}, y={xy_in[1]:.2f}")
|
|
1918
|
+
val = _safe_input(f"Enter new y position (current x: {xy_in[0]:.2f}, q=back): ").strip()
|
|
1919
|
+
if not val or val.lower() == 'q':
|
|
1920
|
+
break
|
|
1921
|
+
try:
|
|
1922
|
+
y_in = float(val)
|
|
1923
|
+
except (ValueError, KeyboardInterrupt):
|
|
1924
|
+
print("Invalid number, ignored.")
|
|
1925
|
+
continue
|
|
1926
|
+
push_state("legend-position")
|
|
1927
|
+
try:
|
|
1928
|
+
fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (xy_in[0], y_in))
|
|
1929
|
+
# If legend visible, reposition now
|
|
1930
|
+
leg = ax.get_legend()
|
|
1931
|
+
if leg is not None and leg.get_visible():
|
|
1932
|
+
if not _apply_legend_position(fig, ax):
|
|
1933
|
+
handles, labels = _visible_legend_entries(ax)
|
|
1934
|
+
if handles:
|
|
1935
|
+
_legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
|
|
1936
|
+
fig.canvas.draw_idle()
|
|
1937
|
+
print(f"Legend position updated: x={xy_in[0]:.2f}, y={y_in:.2f}")
|
|
1938
|
+
except Exception:
|
|
1939
|
+
pass
|
|
1940
|
+
else:
|
|
1941
|
+
# Try to parse as "x y" format
|
|
1942
|
+
parts = pos_cmd.replace(',', ' ').split()
|
|
1943
|
+
if len(parts) != 2:
|
|
1944
|
+
print("Need two numbers or 'x'/'y' command."); continue
|
|
1945
|
+
try:
|
|
1946
|
+
x_in = float(parts[0]); y_in = float(parts[1])
|
|
1947
|
+
except Exception:
|
|
1948
|
+
print("Invalid numbers."); continue
|
|
1949
|
+
push_state("legend-position")
|
|
1950
|
+
try:
|
|
1951
|
+
fig._ec_legend_xy_in = _sanitize_legend_offset(fig, (x_in, y_in))
|
|
1952
|
+
# If legend visible, reposition now
|
|
1953
|
+
leg = ax.get_legend()
|
|
1954
|
+
if leg is not None and leg.get_visible():
|
|
1955
|
+
if not _apply_legend_position(fig, ax):
|
|
1956
|
+
handles, labels = _visible_legend_entries(ax)
|
|
1957
|
+
if handles:
|
|
1958
|
+
_legend_no_frame(ax, handles, labels, loc='best', borderaxespad=1.0)
|
|
1959
|
+
fig.canvas.draw_idle()
|
|
1960
|
+
print(f"Legend position updated: x={x_in:.2f}, y={y_in:.2f}")
|
|
1961
|
+
except Exception:
|
|
1962
|
+
pass
|
|
1963
|
+
else:
|
|
1964
|
+
print("Unknown option.")
|
|
1965
|
+
except Exception:
|
|
1966
|
+
pass
|
|
1967
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
1968
|
+
continue
|
|
1969
|
+
elif key == 'p':
|
|
1970
|
+
# Print/export style or style+geometry
|
|
1971
|
+
try:
|
|
1972
|
+
style_menu_active = True
|
|
1973
|
+
while style_menu_active:
|
|
1974
|
+
# Print style info first
|
|
1975
|
+
cfg = _get_style_snapshot(fig, ax, cycle_lines, tick_state)
|
|
1976
|
+
cfg['kind'] = 'ec_style' # Default, will be updated if psg is chosen
|
|
1977
|
+
_print_style_snapshot(cfg)
|
|
1978
|
+
|
|
1979
|
+
# List available style files (.bps, .bpsg, .bpcfg) in Styles/ subdirectory
|
|
1980
|
+
style_file_list = list_files_in_subdirectory(('.bps', '.bpsg', '.bpcfg'), 'style')
|
|
1981
|
+
_bpcfg_files = [f[0] for f in style_file_list]
|
|
1982
|
+
if _bpcfg_files:
|
|
1983
|
+
print("Existing style files in Styles/ (.bps/.bpsg):")
|
|
1984
|
+
for _i, (fname, fpath) in enumerate(style_file_list, 1):
|
|
1985
|
+
timestamp = _format_file_timestamp(fpath)
|
|
1986
|
+
if timestamp:
|
|
1987
|
+
print(f" {_i}: {fname} ({timestamp})")
|
|
1988
|
+
else:
|
|
1989
|
+
print(f" {_i}: {fname}")
|
|
1990
|
+
|
|
1991
|
+
last_style_path = getattr(fig, '_last_style_export_path', None)
|
|
1992
|
+
if last_style_path:
|
|
1993
|
+
sub = _safe_input(_colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
|
|
1994
|
+
else:
|
|
1995
|
+
sub = _safe_input(_colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
|
|
1996
|
+
if sub == 'q':
|
|
1997
|
+
break
|
|
1998
|
+
if sub == 'r' or sub == '':
|
|
1999
|
+
continue
|
|
2000
|
+
if sub == 'o':
|
|
2001
|
+
# Overwrite last exported style file
|
|
2002
|
+
if not last_style_path:
|
|
2003
|
+
print("No previous export found.")
|
|
2004
|
+
continue
|
|
2005
|
+
if not os.path.exists(last_style_path):
|
|
2006
|
+
print(f"Previous export file not found: {last_style_path}")
|
|
2007
|
+
continue
|
|
2008
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
|
|
2009
|
+
if yn != 'y':
|
|
2010
|
+
continue
|
|
2011
|
+
# Rebuild config based on current state
|
|
2012
|
+
cfg = _get_style_snapshot(fig, ax, cycle_lines, tick_state)
|
|
2013
|
+
# Determine if last export was style-only or style+geometry
|
|
2014
|
+
try:
|
|
2015
|
+
with open(last_style_path, 'r', encoding='utf-8') as f:
|
|
2016
|
+
old_cfg = json.load(f)
|
|
2017
|
+
if old_cfg.get('kind') == 'ec_style_geom':
|
|
2018
|
+
geom = _get_geometry_snapshot(fig, ax)
|
|
2019
|
+
cfg['kind'] = 'ec_style_geom'
|
|
2020
|
+
cfg['geometry'] = geom
|
|
2021
|
+
else:
|
|
2022
|
+
cfg['kind'] = 'ec_style'
|
|
2023
|
+
except Exception:
|
|
2024
|
+
cfg['kind'] = 'ec_style'
|
|
2025
|
+
with open(last_style_path, 'w', encoding='utf-8') as f:
|
|
2026
|
+
json.dump(cfg, f, indent=2)
|
|
2027
|
+
print(f"Overwritten style to {last_style_path}")
|
|
2028
|
+
style_menu_active = False
|
|
2029
|
+
break
|
|
2030
|
+
if sub == 'e':
|
|
2031
|
+
# Ask for ps or psg
|
|
2032
|
+
print("Export options:")
|
|
2033
|
+
print(" ps = style only (.bps)")
|
|
2034
|
+
print(" psg = style + geometry (.bpsg)")
|
|
2035
|
+
exp_choice = _safe_input(_colorize_prompt("Export choice (ps/psg, q=cancel): ")).strip().lower()
|
|
2036
|
+
if not exp_choice or exp_choice == 'q':
|
|
2037
|
+
print("Style export canceled.")
|
|
2038
|
+
continue
|
|
2039
|
+
|
|
2040
|
+
if exp_choice == 'ps':
|
|
2041
|
+
# Style only
|
|
2042
|
+
cfg = _get_style_snapshot(fig, ax, cycle_lines, tick_state)
|
|
2043
|
+
cfg['kind'] = 'ec_style'
|
|
2044
|
+
default_ext = '.bps'
|
|
2045
|
+
elif exp_choice == 'psg':
|
|
2046
|
+
# Style + Geometry
|
|
2047
|
+
cfg = _get_style_snapshot(fig, ax, cycle_lines, tick_state)
|
|
2048
|
+
geom = _get_geometry_snapshot(fig, ax)
|
|
2049
|
+
cfg['kind'] = 'ec_style_geom'
|
|
2050
|
+
cfg['geometry'] = geom
|
|
2051
|
+
default_ext = '.bpsg'
|
|
2052
|
+
print("\n--- Geometry ---")
|
|
2053
|
+
print(f"X-axis label: {geom['xlabel']}")
|
|
2054
|
+
print(f"Y-axis label: {geom['ylabel']}")
|
|
2055
|
+
print(f"X limits: {geom['xlim'][0]:.4g} to {geom['xlim'][1]:.4g}")
|
|
2056
|
+
print(f"Y limits: {geom['ylim'][0]:.4g} to {geom['ylim'][1]:.4g}")
|
|
2057
|
+
else:
|
|
2058
|
+
print(f"Unknown option: {exp_choice}")
|
|
2059
|
+
continue
|
|
2060
|
+
|
|
2061
|
+
save_base = choose_save_path(source_paths, purpose="style export")
|
|
2062
|
+
if not save_base:
|
|
2063
|
+
print("Style export canceled.")
|
|
2064
|
+
continue
|
|
2065
|
+
print(f"\nChosen path: {save_base}")
|
|
2066
|
+
exported_path = _export_style_dialog(cfg, default_ext=default_ext, base_path=save_base)
|
|
2067
|
+
if exported_path:
|
|
2068
|
+
fig._last_style_export_path = exported_path
|
|
2069
|
+
style_menu_active = False # Exit style submenu and return to main menu
|
|
2070
|
+
break
|
|
2071
|
+
else:
|
|
2072
|
+
print("Unknown choice.")
|
|
2073
|
+
except Exception as e:
|
|
2074
|
+
print(f"Error in style submenu: {e}")
|
|
2075
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2076
|
+
continue
|
|
2077
|
+
elif key == 'i':
|
|
2078
|
+
# Import style from .bps/.bpsg/.bpcfg
|
|
2079
|
+
try:
|
|
2080
|
+
path = choose_style_file(source_paths, purpose="style import")
|
|
2081
|
+
if not path:
|
|
2082
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2083
|
+
continue
|
|
2084
|
+
push_state("import-style")
|
|
2085
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
2086
|
+
cfg = json.load(f)
|
|
2087
|
+
|
|
2088
|
+
# Check file type
|
|
2089
|
+
kind = cfg.get('kind', '')
|
|
2090
|
+
if kind not in ('ec_style', 'ec_style_geom'):
|
|
2091
|
+
print("Not an EC style file.")
|
|
2092
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2093
|
+
continue
|
|
2094
|
+
|
|
2095
|
+
has_geometry = (kind == 'ec_style_geom' and 'geometry' in cfg)
|
|
2096
|
+
|
|
2097
|
+
# Save current labelpad values and axes position BEFORE any style changes
|
|
2098
|
+
saved_xlabelpad = None
|
|
2099
|
+
saved_ylabelpad = None
|
|
2100
|
+
saved_axes_position = None
|
|
2101
|
+
try:
|
|
2102
|
+
saved_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
2103
|
+
except Exception:
|
|
2104
|
+
pass
|
|
2105
|
+
try:
|
|
2106
|
+
saved_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
2107
|
+
except Exception:
|
|
2108
|
+
pass
|
|
2109
|
+
try:
|
|
2110
|
+
# Save current axes position to detect if it actually changes
|
|
2111
|
+
saved_axes_position = ax.get_position()
|
|
2112
|
+
except Exception:
|
|
2113
|
+
pass
|
|
2114
|
+
|
|
2115
|
+
# --- Apply comprehensive style (no curve data) ---
|
|
2116
|
+
# Figure and font
|
|
2117
|
+
try:
|
|
2118
|
+
fig_cfg = cfg.get('figure', {})
|
|
2119
|
+
# Get axes_fraction BEFORE changing canvas size (to preserve exact position)
|
|
2120
|
+
axes_frac = fig_cfg.get('axes_fraction')
|
|
2121
|
+
frame_size = fig_cfg.get('frame_size')
|
|
2122
|
+
|
|
2123
|
+
canvas_size = fig_cfg.get('canvas_size')
|
|
2124
|
+
if canvas_size and isinstance(canvas_size, list) and len(canvas_size) == 2:
|
|
2125
|
+
# Use forward=False to prevent automatic subplot adjustment that can shift the plot
|
|
2126
|
+
# We'll restore axes_fraction immediately after to set exact position
|
|
2127
|
+
fig.set_size_inches(canvas_size[0], canvas_size[1], forward=False)
|
|
2128
|
+
|
|
2129
|
+
# Frame position: prefer axes_fraction (exact position), fall back to centering based on frame_size
|
|
2130
|
+
axes_position_changed = False
|
|
2131
|
+
if axes_frac and isinstance(axes_frac, (list, tuple)) and len(axes_frac) == 4:
|
|
2132
|
+
# Restore exact position from axes_fraction (this overrides any automatic adjustments)
|
|
2133
|
+
x0, y0, w, h = axes_frac
|
|
2134
|
+
left = float(x0)
|
|
2135
|
+
bottom = float(y0)
|
|
2136
|
+
right = left + float(w)
|
|
2137
|
+
top = bottom + float(h)
|
|
2138
|
+
if 0 < left < right <= 1 and 0 < bottom < top <= 1:
|
|
2139
|
+
# Check if axes position actually changed
|
|
2140
|
+
if saved_axes_position is not None:
|
|
2141
|
+
tol = 1e-6
|
|
2142
|
+
if (abs(saved_axes_position.x0 - left) > tol or
|
|
2143
|
+
abs(saved_axes_position.y0 - bottom) > tol or
|
|
2144
|
+
abs(saved_axes_position.width - w) > tol or
|
|
2145
|
+
abs(saved_axes_position.height - h) > tol):
|
|
2146
|
+
axes_position_changed = True
|
|
2147
|
+
# Only call subplots_adjust if position actually changed
|
|
2148
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
2149
|
+
else:
|
|
2150
|
+
axes_position_changed = True
|
|
2151
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
2152
|
+
elif frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
|
|
2153
|
+
# Fall back to centering based on frame_size (for backward compatibility)
|
|
2154
|
+
fw_in, fh_in = frame_size
|
|
2155
|
+
canvas_w, canvas_h = fig.get_size_inches()
|
|
2156
|
+
if canvas_w > 0 and canvas_h > 0:
|
|
2157
|
+
min_margin = 0.05
|
|
2158
|
+
w_frac = min(fw_in / canvas_w, 1 - 2 * min_margin)
|
|
2159
|
+
h_frac = min(fh_in / canvas_h, 1 - 2 * min_margin)
|
|
2160
|
+
left = (1 - w_frac) / 2
|
|
2161
|
+
bottom = (1 - h_frac) / 2
|
|
2162
|
+
# Check if axes position actually changed
|
|
2163
|
+
if saved_axes_position is not None:
|
|
2164
|
+
tol = 1e-6
|
|
2165
|
+
new_pos = (left, bottom, w_frac, h_frac)
|
|
2166
|
+
if (abs(saved_axes_position.x0 - new_pos[0]) > tol or
|
|
2167
|
+
abs(saved_axes_position.y0 - new_pos[1]) > tol or
|
|
2168
|
+
abs(saved_axes_position.width - new_pos[2]) > tol or
|
|
2169
|
+
abs(saved_axes_position.height - new_pos[3]) > tol):
|
|
2170
|
+
axes_position_changed = True
|
|
2171
|
+
# Only call subplots_adjust if position actually changed
|
|
2172
|
+
fig.subplots_adjust(left=left, right=left + w_frac, bottom=bottom, top=bottom + h_frac)
|
|
2173
|
+
else:
|
|
2174
|
+
axes_position_changed = True
|
|
2175
|
+
fig.subplots_adjust(left=left, right=left + w_frac, bottom=bottom, top=bottom + h_frac)
|
|
2176
|
+
|
|
2177
|
+
font_cfg = cfg.get('font', {})
|
|
2178
|
+
if font_cfg.get('family'):
|
|
2179
|
+
_apply_font_family(ax, font_cfg['family'])
|
|
2180
|
+
if font_cfg.get('size') is not None:
|
|
2181
|
+
_apply_font_size(ax, float(font_cfg['size']))
|
|
2182
|
+
except Exception as e:
|
|
2183
|
+
print(f"Warning: Could not apply figure/font settings: {e}")
|
|
2184
|
+
|
|
2185
|
+
# WASD state and dependent components
|
|
2186
|
+
try:
|
|
2187
|
+
wasd_state = cfg.get('wasd_state')
|
|
2188
|
+
if wasd_state and isinstance(wasd_state, dict):
|
|
2189
|
+
# Apply spines
|
|
2190
|
+
for name in ('top','bottom','left','right'):
|
|
2191
|
+
side = wasd_state.get(name, {})
|
|
2192
|
+
if name in ax.spines and 'spine' in side:
|
|
2193
|
+
ax.spines[name].set_visible(bool(side['spine']))
|
|
2194
|
+
|
|
2195
|
+
# Apply major ticks & labels
|
|
2196
|
+
top_s = wasd_state.get('top', {})
|
|
2197
|
+
bot_s = wasd_state.get('bottom', {})
|
|
2198
|
+
left_s = wasd_state.get('left', {})
|
|
2199
|
+
right_s = wasd_state.get('right', {})
|
|
2200
|
+
|
|
2201
|
+
ax.tick_params(axis='x',
|
|
2202
|
+
top=bool(top_s.get('ticks', False)),
|
|
2203
|
+
bottom=bool(bot_s.get('ticks', True)),
|
|
2204
|
+
labeltop=bool(top_s.get('labels', False)),
|
|
2205
|
+
labelbottom=bool(bot_s.get('labels', True)))
|
|
2206
|
+
ax.tick_params(axis='y',
|
|
2207
|
+
left=bool(left_s.get('ticks', True)),
|
|
2208
|
+
right=bool(right_s.get('ticks', False)),
|
|
2209
|
+
labelleft=bool(left_s.get('labels', True)),
|
|
2210
|
+
labelright=bool(right_s.get('labels', False)))
|
|
2211
|
+
|
|
2212
|
+
# Apply minor ticks - only set locator if minor ticks are enabled, otherwise clear it
|
|
2213
|
+
if top_s.get('minor') or bot_s.get('minor'):
|
|
2214
|
+
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
2215
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
2216
|
+
else:
|
|
2217
|
+
# Clear minor locator if no minor ticks are enabled
|
|
2218
|
+
ax.xaxis.set_minor_locator(NullLocator())
|
|
2219
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
2220
|
+
ax.tick_params(axis='x', which='minor',
|
|
2221
|
+
top=bool(top_s.get('minor', False)),
|
|
2222
|
+
bottom=bool(bot_s.get('minor', False)),
|
|
2223
|
+
labeltop=False, labelbottom=False)
|
|
2224
|
+
|
|
2225
|
+
if left_s.get('minor') or right_s.get('minor'):
|
|
2226
|
+
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
2227
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
2228
|
+
else:
|
|
2229
|
+
# Clear minor locator if no minor ticks are enabled
|
|
2230
|
+
ax.yaxis.set_minor_locator(NullLocator())
|
|
2231
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
2232
|
+
ax.tick_params(axis='y', which='minor',
|
|
2233
|
+
left=bool(left_s.get('minor', False)),
|
|
2234
|
+
right=bool(right_s.get('minor', False)),
|
|
2235
|
+
labelleft=False, labelright=False)
|
|
2236
|
+
|
|
2237
|
+
# Apply axis titles
|
|
2238
|
+
ax._top_xlabel_on = bool(top_s.get('title', False))
|
|
2239
|
+
ax._right_ylabel_on = bool(right_s.get('title', False))
|
|
2240
|
+
|
|
2241
|
+
# Update tick_state for consistency
|
|
2242
|
+
tick_state['t_ticks'] = bool(top_s.get('ticks', False))
|
|
2243
|
+
tick_state['t_labels'] = bool(top_s.get('labels', False))
|
|
2244
|
+
tick_state['b_ticks'] = bool(bot_s.get('ticks', True))
|
|
2245
|
+
tick_state['b_labels'] = bool(bot_s.get('labels', True))
|
|
2246
|
+
tick_state['l_ticks'] = bool(left_s.get('ticks', True))
|
|
2247
|
+
tick_state['l_labels'] = bool(left_s.get('labels', True))
|
|
2248
|
+
tick_state['r_ticks'] = bool(right_s.get('ticks', False))
|
|
2249
|
+
tick_state['r_labels'] = bool(right_s.get('labels', False))
|
|
2250
|
+
tick_state['mtx'] = bool(top_s.get('minor', False))
|
|
2251
|
+
tick_state['mbx'] = bool(bot_s.get('minor', False))
|
|
2252
|
+
tick_state['mly'] = bool(left_s.get('minor', False))
|
|
2253
|
+
tick_state['mry'] = bool(right_s.get('minor', False))
|
|
2254
|
+
|
|
2255
|
+
# Don't reposition labels here - do it at the end after all style changes
|
|
2256
|
+
# This prevents font changes and other operations from triggering unnecessary recalculations
|
|
2257
|
+
|
|
2258
|
+
except Exception as e:
|
|
2259
|
+
print(f"Warning: Could not apply tick visibility: {e}")
|
|
2260
|
+
|
|
2261
|
+
# Spines and Ticks (widths)
|
|
2262
|
+
try:
|
|
2263
|
+
spines_cfg = cfg.get('spines', {})
|
|
2264
|
+
for name, props in spines_cfg.items():
|
|
2265
|
+
if name in ax.spines:
|
|
2266
|
+
if props.get('linewidth') is not None:
|
|
2267
|
+
ax.spines[name].set_linewidth(props['linewidth'])
|
|
2268
|
+
if props.get('color') is not None:
|
|
2269
|
+
_apply_spine_color(ax, fig, tick_state, name, props['color'])
|
|
2270
|
+
|
|
2271
|
+
tick_widths = cfg.get('ticks', {}).get('widths', {})
|
|
2272
|
+
if tick_widths.get('x_major') is not None: ax.tick_params(axis='x', which='major', width=tick_widths['x_major'])
|
|
2273
|
+
if tick_widths.get('x_minor') is not None: ax.tick_params(axis='x', which='minor', width=tick_widths['x_minor'])
|
|
2274
|
+
if tick_widths.get('y_major') is not None: ax.tick_params(axis='y', which='major', width=tick_widths['y_major'])
|
|
2275
|
+
if tick_widths.get('y_minor') is not None: ax.tick_params(axis='y', which='minor', width=tick_widths['y_minor'])
|
|
2276
|
+
|
|
2277
|
+
# Apply tick direction
|
|
2278
|
+
tick_direction = cfg.get('ticks', {}).get('direction', 'out')
|
|
2279
|
+
if tick_direction:
|
|
2280
|
+
setattr(fig, '_tick_direction', tick_direction)
|
|
2281
|
+
ax.tick_params(axis='both', which='both', direction=tick_direction)
|
|
2282
|
+
except Exception: pass
|
|
2283
|
+
|
|
2284
|
+
# Grid state
|
|
2285
|
+
try:
|
|
2286
|
+
grid_enabled = cfg.get('grid', False)
|
|
2287
|
+
if grid_enabled:
|
|
2288
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
2289
|
+
else:
|
|
2290
|
+
ax.grid(False)
|
|
2291
|
+
except Exception: pass
|
|
2292
|
+
|
|
2293
|
+
# Rotation angle
|
|
2294
|
+
try:
|
|
2295
|
+
rotation_angle = cfg.get('rotation_angle', 0)
|
|
2296
|
+
setattr(fig, '_ec_rotation_angle', rotation_angle)
|
|
2297
|
+
except Exception: pass
|
|
2298
|
+
|
|
2299
|
+
# Curve linewidth (single value for all curves)
|
|
2300
|
+
try:
|
|
2301
|
+
curve_linewidth = cfg.get('curve_linewidth')
|
|
2302
|
+
if curve_linewidth is not None:
|
|
2303
|
+
# Store globally on fig so it persists
|
|
2304
|
+
setattr(fig, '_ec_curve_linewidth', float(curve_linewidth))
|
|
2305
|
+
# Apply to all curves
|
|
2306
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2307
|
+
try:
|
|
2308
|
+
ln.set_linewidth(float(curve_linewidth))
|
|
2309
|
+
except Exception:
|
|
2310
|
+
pass
|
|
2311
|
+
except Exception: pass
|
|
2312
|
+
|
|
2313
|
+
# Curve marker properties (linestyle, marker, markersize, colors)
|
|
2314
|
+
try:
|
|
2315
|
+
curve_markers = cfg.get('curve_markers', {})
|
|
2316
|
+
if curve_markers:
|
|
2317
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2318
|
+
try:
|
|
2319
|
+
if 'linestyle' in curve_markers:
|
|
2320
|
+
ln.set_linestyle(curve_markers['linestyle'])
|
|
2321
|
+
if 'marker' in curve_markers:
|
|
2322
|
+
ln.set_marker(curve_markers['marker'])
|
|
2323
|
+
if 'markersize' in curve_markers:
|
|
2324
|
+
ln.set_markersize(curve_markers['markersize'])
|
|
2325
|
+
if 'markerfacecolor' in curve_markers:
|
|
2326
|
+
ln.set_markerfacecolor(curve_markers['markerfacecolor'])
|
|
2327
|
+
if 'markeredgecolor' in curve_markers:
|
|
2328
|
+
ln.set_markeredgecolor(curve_markers['markeredgecolor'])
|
|
2329
|
+
except Exception:
|
|
2330
|
+
pass
|
|
2331
|
+
except Exception: pass
|
|
2332
|
+
|
|
2333
|
+
# Legend visibility/position
|
|
2334
|
+
legend_cfg = cfg.get('legend', {}) or {}
|
|
2335
|
+
legend_visible = None
|
|
2336
|
+
try:
|
|
2337
|
+
if legend_cfg:
|
|
2338
|
+
legend_visible = bool(legend_cfg.get('visible', True))
|
|
2339
|
+
xy = legend_cfg.get('position_inches')
|
|
2340
|
+
if xy is not None:
|
|
2341
|
+
fig._ec_legend_xy_in = _sanitize_legend_offset(fig, xy)
|
|
2342
|
+
else:
|
|
2343
|
+
fig._ec_legend_xy_in = None
|
|
2344
|
+
if 'title' in legend_cfg and legend_cfg['title']:
|
|
2345
|
+
fig._ec_legend_title = legend_cfg['title']
|
|
2346
|
+
fig._ec_legend_user_visible = bool(legend_visible)
|
|
2347
|
+
except Exception:
|
|
2348
|
+
legend_visible = None
|
|
2349
|
+
|
|
2350
|
+
cycle_styles_cfg = cfg.get('cycle_styles')
|
|
2351
|
+
if cycle_styles_cfg:
|
|
2352
|
+
_apply_cycle_styles(cycle_lines, cycle_styles_cfg)
|
|
2353
|
+
|
|
2354
|
+
# Apply geometry if present (before final repositioning)
|
|
2355
|
+
if has_geometry:
|
|
2356
|
+
try:
|
|
2357
|
+
geom = cfg.get('geometry', {})
|
|
2358
|
+
if 'xlabel' in geom and geom['xlabel']:
|
|
2359
|
+
ax.set_xlabel(geom['xlabel'])
|
|
2360
|
+
if 'ylabel' in geom and geom['ylabel']:
|
|
2361
|
+
ax.set_ylabel(geom['ylabel'])
|
|
2362
|
+
if 'xlim' in geom and isinstance(geom['xlim'], list) and len(geom['xlim']) == 2:
|
|
2363
|
+
ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
|
|
2364
|
+
if 'ylim' in geom and isinstance(geom['ylim'], list) and len(geom['ylim']) == 2:
|
|
2365
|
+
ax.set_ylim(geom['ylim'][0], geom['ylim'][1])
|
|
2366
|
+
print("Applied geometry (labels and limits)")
|
|
2367
|
+
except Exception as e:
|
|
2368
|
+
print(f"Warning: Could not apply geometry: {e}")
|
|
2369
|
+
|
|
2370
|
+
# Restore title offsets
|
|
2371
|
+
try:
|
|
2372
|
+
offsets = cfg.get('title_offsets', {})
|
|
2373
|
+
if offsets:
|
|
2374
|
+
ax._top_xlabel_manual_offset_y_pts = float(offsets.get('top_y', 0.0) or 0.0)
|
|
2375
|
+
ax._top_xlabel_manual_offset_x_pts = float(offsets.get('top_x', 0.0) or 0.0)
|
|
2376
|
+
ax._bottom_xlabel_manual_offset_y_pts = float(offsets.get('bottom_y', 0.0) or 0.0)
|
|
2377
|
+
ax._left_ylabel_manual_offset_x_pts = float(offsets.get('left_x', 0.0) or 0.0)
|
|
2378
|
+
ax._right_ylabel_manual_offset_x_pts = float(offsets.get('right_x', 0.0) or 0.0)
|
|
2379
|
+
ax._right_ylabel_manual_offset_y_pts = float(offsets.get('right_y', 0.0) or 0.0)
|
|
2380
|
+
except Exception:
|
|
2381
|
+
pass
|
|
2382
|
+
|
|
2383
|
+
# Final label positioning - do this AFTER all style changes to prevent drift
|
|
2384
|
+
# Set pending labelpad before repositioning to preserve original values
|
|
2385
|
+
try:
|
|
2386
|
+
if saved_xlabelpad is not None:
|
|
2387
|
+
ax._pending_xlabelpad = saved_xlabelpad
|
|
2388
|
+
if saved_ylabelpad is not None:
|
|
2389
|
+
ax._pending_ylabelpad = saved_ylabelpad
|
|
2390
|
+
|
|
2391
|
+
# Only reposition if axes position actually changed OR if fonts changed
|
|
2392
|
+
# This prevents unnecessary movement when nothing actually changed
|
|
2393
|
+
font_cfg = cfg.get('font', {})
|
|
2394
|
+
font_changed = (font_cfg.get('family') is not None or font_cfg.get('size') is not None)
|
|
2395
|
+
|
|
2396
|
+
# Always reposition titles to apply offsets (even if nothing else changed)
|
|
2397
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
2398
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
2399
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
2400
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
2401
|
+
|
|
2402
|
+
# Always ensure labelpad is exactly as it was before style import
|
|
2403
|
+
# This is a final safeguard against any drift
|
|
2404
|
+
if saved_xlabelpad is not None:
|
|
2405
|
+
ax.xaxis.labelpad = saved_xlabelpad
|
|
2406
|
+
if saved_ylabelpad is not None:
|
|
2407
|
+
ax.yaxis.labelpad = saved_ylabelpad
|
|
2408
|
+
except Exception:
|
|
2409
|
+
pass
|
|
2410
|
+
|
|
2411
|
+
# Rebuild and reposition legend after all changes (including figure size changes)
|
|
2412
|
+
_rebuild_legend(ax)
|
|
2413
|
+
if legend_cfg:
|
|
2414
|
+
try:
|
|
2415
|
+
if legend_visible:
|
|
2416
|
+
_apply_legend_position(fig, ax)
|
|
2417
|
+
leg = ax.get_legend()
|
|
2418
|
+
if leg is not None:
|
|
2419
|
+
leg.set_visible(bool(legend_visible))
|
|
2420
|
+
_set_legend_user_pref(fig, bool(legend_visible))
|
|
2421
|
+
except Exception:
|
|
2422
|
+
pass
|
|
2423
|
+
|
|
2424
|
+
fig.canvas.draw_idle()
|
|
2425
|
+
print(f"Applied style from {path}")
|
|
2426
|
+
|
|
2427
|
+
except Exception as e:
|
|
2428
|
+
print(f"Error importing style: {e}")
|
|
2429
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2430
|
+
continue
|
|
2431
|
+
elif key == 'l':
|
|
2432
|
+
# Line widths submenu: curves vs frame/ticks
|
|
2433
|
+
try:
|
|
2434
|
+
def _tick_width(axis_obj, which: str):
|
|
2435
|
+
try:
|
|
2436
|
+
tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
|
|
2437
|
+
width = tick_kw.get('width')
|
|
2438
|
+
if width is None:
|
|
2439
|
+
axis_name = getattr(axis_obj, 'axis_name', 'x')
|
|
2440
|
+
rc_key = f"{axis_name}tick.{which}.width"
|
|
2441
|
+
width = plt.rcParams.get(rc_key)
|
|
2442
|
+
if width is not None:
|
|
2443
|
+
return float(width)
|
|
2444
|
+
except Exception:
|
|
2445
|
+
return None
|
|
2446
|
+
return None
|
|
2447
|
+
while True:
|
|
2448
|
+
# Show current widths summary
|
|
2449
|
+
try:
|
|
2450
|
+
cur_sp_lw = {name: (ax.spines.get(name).get_linewidth() if ax.spines.get(name) else None)
|
|
2451
|
+
for name in ('bottom','top','left','right')}
|
|
2452
|
+
except Exception:
|
|
2453
|
+
cur_sp_lw = {}
|
|
2454
|
+
x_maj = _tick_width(ax.xaxis, 'major')
|
|
2455
|
+
x_min = _tick_width(ax.xaxis, 'minor')
|
|
2456
|
+
y_maj = _tick_width(ax.yaxis, 'major')
|
|
2457
|
+
y_min = _tick_width(ax.yaxis, 'minor')
|
|
2458
|
+
# Curve linewidth: get single stored value or from first curve
|
|
2459
|
+
cur_curve_lw = getattr(fig, '_ec_curve_linewidth', None)
|
|
2460
|
+
if cur_curve_lw is None:
|
|
2461
|
+
try:
|
|
2462
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2463
|
+
try:
|
|
2464
|
+
cur_curve_lw = float(ln.get_linewidth() or 1.0)
|
|
2465
|
+
break
|
|
2466
|
+
except Exception:
|
|
2467
|
+
pass
|
|
2468
|
+
if cur_curve_lw is not None:
|
|
2469
|
+
break
|
|
2470
|
+
except Exception:
|
|
2471
|
+
pass
|
|
2472
|
+
print("Line widths:")
|
|
2473
|
+
if cur_sp_lw:
|
|
2474
|
+
print(" Frame spines lw:",
|
|
2475
|
+
" ".join(f"{k}={v:.3g}" if isinstance(v,(int,float)) else f"{k}=?" for k,v in cur_sp_lw.items()))
|
|
2476
|
+
print(f" Tick widths: xM={x_maj if x_maj is not None else '?'} xm={x_min if x_min is not None else '?'} yM={y_maj if y_maj is not None else '?'} ym={y_min if y_min is not None else '?'}")
|
|
2477
|
+
if cur_curve_lw is not None:
|
|
2478
|
+
print(f" Curves (all): {cur_curve_lw:.3g}")
|
|
2479
|
+
print("\033[1mLine submenu:\033[0m")
|
|
2480
|
+
print(f" {_colorize_menu('c : change curve line widths')}")
|
|
2481
|
+
print(f" {_colorize_menu('f : change frame (axes spines) and tick widths')}")
|
|
2482
|
+
print(f" {_colorize_menu('g : toggle grid lines')}")
|
|
2483
|
+
print(f" {_colorize_menu('l : show only lines (no markers) for all curves')}")
|
|
2484
|
+
print(f" {_colorize_menu('ld : show line and dots (markers) for all curves')}")
|
|
2485
|
+
print(f" {_colorize_menu('d : show only dots (no connecting line) for all curves')}")
|
|
2486
|
+
print(f" {_colorize_menu('q : return')}")
|
|
2487
|
+
sub = _safe_input(_colorize_prompt("Choose (c/f/g/l/ld/d/q): ")).strip().lower()
|
|
2488
|
+
if not sub:
|
|
2489
|
+
continue
|
|
2490
|
+
if sub == 'q':
|
|
2491
|
+
break
|
|
2492
|
+
if sub == 'c':
|
|
2493
|
+
spec = _safe_input("Curve linewidth (single value for all curves, q=cancel): ").strip()
|
|
2494
|
+
if not spec or spec.lower() == 'q':
|
|
2495
|
+
continue
|
|
2496
|
+
# Apply single width to all curves
|
|
2497
|
+
try:
|
|
2498
|
+
push_state("curve-linewidth")
|
|
2499
|
+
lw = float(spec)
|
|
2500
|
+
# Store globally on fig so it persists
|
|
2501
|
+
setattr(fig, '_ec_curve_linewidth', lw)
|
|
2502
|
+
# Apply to all curves
|
|
2503
|
+
for cyc, parts in cycle_lines.items():
|
|
2504
|
+
for role in ("charge","discharge"):
|
|
2505
|
+
ln = parts.get(role)
|
|
2506
|
+
if ln is not None:
|
|
2507
|
+
try: ln.set_linewidth(lw)
|
|
2508
|
+
except Exception: pass
|
|
2509
|
+
try:
|
|
2510
|
+
_rebuild_legend(ax)
|
|
2511
|
+
fig.canvas.draw()
|
|
2512
|
+
except Exception:
|
|
2513
|
+
try:
|
|
2514
|
+
_rebuild_legend(ax)
|
|
2515
|
+
except Exception:
|
|
2516
|
+
pass
|
|
2517
|
+
fig.canvas.draw_idle()
|
|
2518
|
+
print(f"Set all curve linewidths to {lw}")
|
|
2519
|
+
except ValueError:
|
|
2520
|
+
print("Invalid width value.")
|
|
2521
|
+
elif sub == 'f':
|
|
2522
|
+
fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
|
|
2523
|
+
if not fw_in or fw_in.lower() == 'q':
|
|
2524
|
+
print("Canceled.")
|
|
2525
|
+
continue
|
|
2526
|
+
parts = fw_in.split()
|
|
2527
|
+
try:
|
|
2528
|
+
push_state("framewidth")
|
|
2529
|
+
if len(parts) == 1:
|
|
2530
|
+
frame_w = float(parts[0])
|
|
2531
|
+
tick_major = frame_w
|
|
2532
|
+
tick_minor = frame_w * 0.6
|
|
2533
|
+
else:
|
|
2534
|
+
frame_w = float(parts[0])
|
|
2535
|
+
tick_major = float(parts[1])
|
|
2536
|
+
tick_minor = float(tick_major) * 0.7
|
|
2537
|
+
for sp in ax.spines.values():
|
|
2538
|
+
sp.set_linewidth(frame_w)
|
|
2539
|
+
ax.tick_params(which='major', width=tick_major)
|
|
2540
|
+
ax.tick_params(which='minor', width=tick_minor)
|
|
2541
|
+
fig.canvas.draw()
|
|
2542
|
+
print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
|
|
2543
|
+
except ValueError:
|
|
2544
|
+
print("Invalid numeric value(s).")
|
|
2545
|
+
elif sub == 'g':
|
|
2546
|
+
push_state("grid")
|
|
2547
|
+
# Toggle grid state - check if any gridlines are visible
|
|
2548
|
+
current_grid = False
|
|
2549
|
+
try:
|
|
2550
|
+
# Check if grid is currently on by looking at gridline visibility
|
|
2551
|
+
for line in ax.get_xgridlines() + ax.get_ygridlines():
|
|
2552
|
+
if line.get_visible():
|
|
2553
|
+
current_grid = True
|
|
2554
|
+
break
|
|
2555
|
+
except Exception:
|
|
2556
|
+
current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
|
|
2557
|
+
|
|
2558
|
+
new_grid_state = not current_grid
|
|
2559
|
+
if new_grid_state:
|
|
2560
|
+
# Enable grid with light styling
|
|
2561
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
2562
|
+
else:
|
|
2563
|
+
# Disable grid (no style parameters when disabling)
|
|
2564
|
+
ax.grid(False)
|
|
2565
|
+
fig.canvas.draw()
|
|
2566
|
+
print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
|
|
2567
|
+
elif sub == 'l':
|
|
2568
|
+
# Line-only mode: set linestyle to solid and remove markers
|
|
2569
|
+
push_state("line-only")
|
|
2570
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2571
|
+
try:
|
|
2572
|
+
# Check if already in line-only mode (has line style and no marker)
|
|
2573
|
+
current_ls = ln.get_linestyle()
|
|
2574
|
+
current_marker = ln.get_marker()
|
|
2575
|
+
# If already line-only (has line, no marker), skip
|
|
2576
|
+
if current_ls not in ['None', '', ' ', 'none'] and current_marker in ['None', '', ' ', 'none', None]:
|
|
2577
|
+
continue
|
|
2578
|
+
# Otherwise, set to line-only
|
|
2579
|
+
ln.set_linestyle('-')
|
|
2580
|
+
ln.set_marker('None')
|
|
2581
|
+
except Exception:
|
|
2582
|
+
pass
|
|
2583
|
+
try:
|
|
2584
|
+
_rebuild_legend(ax)
|
|
2585
|
+
fig.canvas.draw()
|
|
2586
|
+
except Exception:
|
|
2587
|
+
try:
|
|
2588
|
+
_rebuild_legend(ax)
|
|
2589
|
+
except Exception:
|
|
2590
|
+
pass
|
|
2591
|
+
fig.canvas.draw_idle()
|
|
2592
|
+
print("Applied line-only style to all curves.")
|
|
2593
|
+
elif sub == 'ld':
|
|
2594
|
+
# Line + dots for all curves
|
|
2595
|
+
push_state("line+dots")
|
|
2596
|
+
try:
|
|
2597
|
+
msize_in = _safe_input("Marker size (blank=auto ~3*lw): ").strip()
|
|
2598
|
+
custom_msize = float(msize_in) if msize_in else None
|
|
2599
|
+
except ValueError:
|
|
2600
|
+
custom_msize = None
|
|
2601
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2602
|
+
try:
|
|
2603
|
+
lw = ln.get_linewidth() or 1.0
|
|
2604
|
+
ln.set_linestyle('-')
|
|
2605
|
+
ln.set_marker('o')
|
|
2606
|
+
msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
|
|
2607
|
+
ln.set_markersize(msize)
|
|
2608
|
+
col = ln.get_color()
|
|
2609
|
+
ln.set_markerfacecolor(col)
|
|
2610
|
+
ln.set_markeredgecolor(col)
|
|
2611
|
+
except Exception:
|
|
2612
|
+
pass
|
|
2613
|
+
try:
|
|
2614
|
+
_rebuild_legend(ax)
|
|
2615
|
+
fig.canvas.draw()
|
|
2616
|
+
except Exception:
|
|
2617
|
+
try:
|
|
2618
|
+
_rebuild_legend(ax)
|
|
2619
|
+
except Exception:
|
|
2620
|
+
pass
|
|
2621
|
+
fig.canvas.draw_idle()
|
|
2622
|
+
print("Applied line+dots style to all curves.")
|
|
2623
|
+
elif sub == 'd':
|
|
2624
|
+
# Dots only for all curves
|
|
2625
|
+
push_state("dots-only")
|
|
2626
|
+
try:
|
|
2627
|
+
msize_in = _safe_input("Marker size (blank=auto ~3*lw): ").strip()
|
|
2628
|
+
custom_msize = float(msize_in) if msize_in else None
|
|
2629
|
+
except ValueError:
|
|
2630
|
+
custom_msize = None
|
|
2631
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
2632
|
+
try:
|
|
2633
|
+
lw = ln.get_linewidth() or 1.0
|
|
2634
|
+
ln.set_linestyle('None')
|
|
2635
|
+
ln.set_marker('o')
|
|
2636
|
+
msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
|
|
2637
|
+
ln.set_markersize(msize)
|
|
2638
|
+
col = ln.get_color()
|
|
2639
|
+
ln.set_markerfacecolor(col)
|
|
2640
|
+
ln.set_markeredgecolor(col)
|
|
2641
|
+
except Exception:
|
|
2642
|
+
pass
|
|
2643
|
+
try:
|
|
2644
|
+
_rebuild_legend(ax)
|
|
2645
|
+
fig.canvas.draw()
|
|
2646
|
+
except Exception:
|
|
2647
|
+
try:
|
|
2648
|
+
_rebuild_legend(ax)
|
|
2649
|
+
except Exception:
|
|
2650
|
+
pass
|
|
2651
|
+
fig.canvas.draw_idle()
|
|
2652
|
+
print("Applied dots-only style to all curves.")
|
|
2653
|
+
else:
|
|
2654
|
+
print("Unknown option.")
|
|
2655
|
+
except Exception as e:
|
|
2656
|
+
print(f"Error in line submenu: {e}")
|
|
2657
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2658
|
+
continue
|
|
2659
|
+
elif key == 'k':
|
|
2660
|
+
# Spine colors (w=top, a=left, s=bottom, d=right)
|
|
2661
|
+
try:
|
|
2662
|
+
while True:
|
|
2663
|
+
print("\nSet spine colors (with matching tick and label colors):")
|
|
2664
|
+
print(_colorize_inline_commands(" w : top spine | a : left spine"))
|
|
2665
|
+
print(_colorize_inline_commands(" s : bottom spine | d : right spine"))
|
|
2666
|
+
print(_colorize_inline_commands("Example: w:red a:#4561F7 s:blue d:green"))
|
|
2667
|
+
user_colors = get_user_color_list(fig)
|
|
2668
|
+
if user_colors:
|
|
2669
|
+
print("\nSaved colors (enter number or u# to reuse):")
|
|
2670
|
+
for idx, color in enumerate(user_colors, 1):
|
|
2671
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
2672
|
+
print("Type 'u' to edit saved colors.")
|
|
2673
|
+
print("q: back to main menu")
|
|
2674
|
+
line = _safe_input("Enter mappings (e.g., w:red a:#4561F7) or q: ").strip()
|
|
2675
|
+
if not line or line.lower() == 'q':
|
|
2676
|
+
break
|
|
2677
|
+
if line.lower() == 'u':
|
|
2678
|
+
manage_user_colors(fig)
|
|
2679
|
+
continue
|
|
2680
|
+
push_state("color-spine")
|
|
2681
|
+
key_to_spine = {'w': 'top', 'a': 'left', 's': 'bottom', 'd': 'right'}
|
|
2682
|
+
tokens = line.split()
|
|
2683
|
+
pairs = []
|
|
2684
|
+
i = 0
|
|
2685
|
+
while i < len(tokens):
|
|
2686
|
+
tok = tokens[i]
|
|
2687
|
+
if ':' in tok:
|
|
2688
|
+
key_part, color = tok.split(':', 1)
|
|
2689
|
+
else:
|
|
2690
|
+
if i + 1 >= len(tokens):
|
|
2691
|
+
print(f"Skip incomplete entry: {tok}")
|
|
2692
|
+
break
|
|
2693
|
+
key_part = tok
|
|
2694
|
+
color = tokens[i + 1]
|
|
2695
|
+
i += 1
|
|
2696
|
+
pairs.append((key_part.lower(), color))
|
|
2697
|
+
i += 1
|
|
2698
|
+
for key_part, color in pairs:
|
|
2699
|
+
if key_part not in key_to_spine:
|
|
2700
|
+
print(f"Unknown key: {key_part} (use w/a/s/d)")
|
|
2701
|
+
continue
|
|
2702
|
+
spine_name = key_to_spine[key_part]
|
|
2703
|
+
if spine_name not in ax.spines:
|
|
2704
|
+
print(f"Spine '{spine_name}' not found.")
|
|
2705
|
+
continue
|
|
2706
|
+
try:
|
|
2707
|
+
resolved = resolve_color_token(color, fig)
|
|
2708
|
+
_apply_spine_color(ax, fig, tick_state, spine_name, resolved)
|
|
2709
|
+
print(f"Set {spine_name} spine to {color_block(resolved)} {resolved}")
|
|
2710
|
+
except Exception as e:
|
|
2711
|
+
print(f"Error setting {spine_name} color: {e}")
|
|
2712
|
+
fig.canvas.draw()
|
|
2713
|
+
except Exception as e:
|
|
2714
|
+
print(f"Error in spine color menu: {e}")
|
|
2715
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2716
|
+
continue
|
|
2717
|
+
elif key == 'r':
|
|
2718
|
+
# Rename axis labels
|
|
2719
|
+
try:
|
|
2720
|
+
print("Tip: Use LaTeX/mathtext for special characters:")
|
|
2721
|
+
print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
|
|
2722
|
+
print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
|
|
2723
|
+
print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
|
|
2724
|
+
while True:
|
|
2725
|
+
print("Rename axis: x, y, both, q=back")
|
|
2726
|
+
sub = _safe_input("Rename> ").strip().lower()
|
|
2727
|
+
if not sub:
|
|
2728
|
+
continue
|
|
2729
|
+
if sub == 'q':
|
|
2730
|
+
break
|
|
2731
|
+
if sub in ('x','both'):
|
|
2732
|
+
txt = _safe_input("New X-axis label (blank=cancel): ")
|
|
2733
|
+
if txt:
|
|
2734
|
+
txt = convert_label_shortcuts(txt)
|
|
2735
|
+
push_state("rename-x")
|
|
2736
|
+
try:
|
|
2737
|
+
# Freeze layout and preserve existing pad for one-shot restore
|
|
2738
|
+
try: fig.set_layout_engine('none')
|
|
2739
|
+
except Exception:
|
|
2740
|
+
try: fig.set_tight_layout(False)
|
|
2741
|
+
except Exception: pass
|
|
2742
|
+
try: fig.set_constrained_layout(False)
|
|
2743
|
+
except Exception: pass
|
|
2744
|
+
try:
|
|
2745
|
+
ax._pending_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
2746
|
+
except Exception:
|
|
2747
|
+
pass
|
|
2748
|
+
ax.set_xlabel(txt)
|
|
2749
|
+
ax._stored_xlabel = txt
|
|
2750
|
+
ax._stored_xlabel_color = ax.xaxis.label.get_color()
|
|
2751
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
2752
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
2753
|
+
except Exception:
|
|
2754
|
+
pass
|
|
2755
|
+
if sub in ('y','both'):
|
|
2756
|
+
txt = _safe_input("New Y-axis label (blank=cancel): ")
|
|
2757
|
+
if txt:
|
|
2758
|
+
txt = convert_label_shortcuts(txt)
|
|
2759
|
+
push_state("rename-y")
|
|
2760
|
+
base_ylabel = txt
|
|
2761
|
+
try:
|
|
2762
|
+
try: fig.set_layout_engine('none')
|
|
2763
|
+
except Exception:
|
|
2764
|
+
try: fig.set_tight_layout(False)
|
|
2765
|
+
except Exception: pass
|
|
2766
|
+
try: fig.set_constrained_layout(False)
|
|
2767
|
+
except Exception: pass
|
|
2768
|
+
try:
|
|
2769
|
+
ax._pending_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
2770
|
+
except Exception:
|
|
2771
|
+
pass
|
|
2772
|
+
ax.set_ylabel(txt)
|
|
2773
|
+
ax._stored_ylabel = txt
|
|
2774
|
+
ax._stored_ylabel_color = ax.yaxis.label.get_color()
|
|
2775
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
2776
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
2777
|
+
except Exception:
|
|
2778
|
+
pass
|
|
2779
|
+
try:
|
|
2780
|
+
fig.canvas.draw()
|
|
2781
|
+
except Exception:
|
|
2782
|
+
fig.canvas.draw_idle()
|
|
2783
|
+
except Exception as e:
|
|
2784
|
+
print(f"Error renaming axes: {e}")
|
|
2785
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2786
|
+
continue
|
|
2787
|
+
elif key == 't':
|
|
2788
|
+
# Unified WASD: w/a/s/d x 1..5 => spine, ticks, minor, labels, title
|
|
2789
|
+
try:
|
|
2790
|
+
wasd = getattr(fig, '_ec_wasd_state', None)
|
|
2791
|
+
if not isinstance(wasd, dict):
|
|
2792
|
+
wasd = {
|
|
2793
|
+
'top': {'spine': _get_spine_visible('top'), 'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))), 'minor': bool(tick_state['mtx']), 'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))), 'title': bool(getattr(ax, '_top_xlabel_on', False))},
|
|
2794
|
+
'bottom': {'spine': _get_spine_visible('bottom'), 'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', False))), 'minor': bool(tick_state['mbx']), 'labels': bool(tick_state.get('b_labels', tick_state.get('bx', False))), 'title': bool(ax.xaxis.label.get_visible())},
|
|
2795
|
+
'left': {'spine': _get_spine_visible('left'), 'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', False))), 'minor': bool(tick_state['mly']), 'labels': bool(tick_state.get('l_labels', tick_state.get('ly', False))), 'title': bool(ax.yaxis.label.get_visible())},
|
|
2796
|
+
'right': {'spine': _get_spine_visible('right'), 'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', False))), 'minor': bool(tick_state['mry']), 'labels': bool(tick_state.get('r_labels', tick_state.get('ry', False))), 'title': bool(getattr(ax, '_right_ylabel_on', False))},
|
|
2797
|
+
}
|
|
2798
|
+
setattr(fig, '_ec_wasd_state', wasd)
|
|
2799
|
+
def _apply_wasd(changed_sides=None):
|
|
2800
|
+
# If no changed_sides specified, reposition all sides (for load style, etc.)
|
|
2801
|
+
if changed_sides is None:
|
|
2802
|
+
changed_sides = {'bottom', 'top', 'left', 'right'}
|
|
2803
|
+
|
|
2804
|
+
# Spines
|
|
2805
|
+
for name in ('top','bottom','left','right'):
|
|
2806
|
+
_set_spine_visible(name, bool(wasd[name]['spine']))
|
|
2807
|
+
# Major ticks & labels
|
|
2808
|
+
ax.tick_params(axis='x', top=bool(wasd['top']['ticks']), bottom=bool(wasd['bottom']['ticks']),
|
|
2809
|
+
labeltop=bool(wasd['top']['labels']), labelbottom=bool(wasd['bottom']['labels']))
|
|
2810
|
+
ax.tick_params(axis='y', left=bool(wasd['left']['ticks']), right=bool(wasd['right']['ticks']),
|
|
2811
|
+
labelleft=bool(wasd['left']['labels']), labelright=bool(wasd['right']['labels']))
|
|
2812
|
+
# Minor X - only set locator if minor ticks are enabled, otherwise clear it
|
|
2813
|
+
if wasd['top']['minor'] or wasd['bottom']['minor']:
|
|
2814
|
+
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
2815
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
2816
|
+
else:
|
|
2817
|
+
# Clear minor locator if no minor ticks are enabled
|
|
2818
|
+
ax.xaxis.set_minor_locator(NullLocator())
|
|
2819
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
2820
|
+
ax.tick_params(axis='x', which='minor', top=bool(wasd['top']['minor']), bottom=bool(wasd['bottom']['minor']), labeltop=False, labelbottom=False)
|
|
2821
|
+
# Minor Y - only set locator if minor ticks are enabled, otherwise clear it
|
|
2822
|
+
if wasd['left']['minor'] or wasd['right']['minor']:
|
|
2823
|
+
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
2824
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
2825
|
+
else:
|
|
2826
|
+
# Clear minor locator if no minor ticks are enabled
|
|
2827
|
+
ax.yaxis.set_minor_locator(NullLocator())
|
|
2828
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
2829
|
+
ax.tick_params(axis='y', which='minor', left=bool(wasd['left']['minor']), right=bool(wasd['right']['minor']), labelleft=False, labelright=False)
|
|
2830
|
+
# Titles
|
|
2831
|
+
if bool(wasd['bottom']['title']):
|
|
2832
|
+
if hasattr(ax,'_stored_xlabel') and isinstance(ax._stored_xlabel,str) and ax._stored_xlabel:
|
|
2833
|
+
ax.set_xlabel(ax._stored_xlabel)
|
|
2834
|
+
ax.xaxis.label.set_visible(True)
|
|
2835
|
+
_apply_stored_axis_colors(ax)
|
|
2836
|
+
else:
|
|
2837
|
+
if not hasattr(ax,'_stored_xlabel'):
|
|
2838
|
+
try: ax._stored_xlabel = ax.get_xlabel()
|
|
2839
|
+
except Exception: ax._stored_xlabel = ''
|
|
2840
|
+
ax.set_xlabel("")
|
|
2841
|
+
ax.xaxis.label.set_visible(False)
|
|
2842
|
+
ax._top_xlabel_on = bool(wasd['top']['title'])
|
|
2843
|
+
if bool(wasd['left']['title']):
|
|
2844
|
+
if hasattr(ax,'_stored_ylabel') and isinstance(ax._stored_ylabel,str) and ax._stored_ylabel:
|
|
2845
|
+
ax.set_ylabel(ax._stored_ylabel)
|
|
2846
|
+
ax.yaxis.label.set_visible(True)
|
|
2847
|
+
_apply_stored_axis_colors(ax)
|
|
2848
|
+
else:
|
|
2849
|
+
if not hasattr(ax,'_stored_ylabel'):
|
|
2850
|
+
try: ax._stored_ylabel = ax.get_ylabel()
|
|
2851
|
+
except Exception: ax._stored_ylabel = ''
|
|
2852
|
+
ax.set_ylabel("")
|
|
2853
|
+
ax.yaxis.label.set_visible(False)
|
|
2854
|
+
ax._right_ylabel_on = bool(wasd['right']['title'])
|
|
2855
|
+
|
|
2856
|
+
# Only reposition sides that were actually changed
|
|
2857
|
+
# This prevents unnecessary title movement when toggling unrelated elements
|
|
2858
|
+
if 'bottom' in changed_sides:
|
|
2859
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
2860
|
+
if 'top' in changed_sides:
|
|
2861
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
2862
|
+
_apply_stored_axis_colors(ax)
|
|
2863
|
+
if 'left' in changed_sides:
|
|
2864
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
2865
|
+
if 'right' in changed_sides:
|
|
2866
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
2867
|
+
_apply_stored_axis_colors(ax)
|
|
2868
|
+
def _sync_tick_state():
|
|
2869
|
+
# Write new separate keys
|
|
2870
|
+
tick_state['t_ticks'] = bool(wasd['top']['ticks'])
|
|
2871
|
+
tick_state['t_labels'] = bool(wasd['top']['labels'])
|
|
2872
|
+
tick_state['b_ticks'] = bool(wasd['bottom']['ticks'])
|
|
2873
|
+
tick_state['b_labels'] = bool(wasd['bottom']['labels'])
|
|
2874
|
+
tick_state['l_ticks'] = bool(wasd['left']['ticks'])
|
|
2875
|
+
tick_state['l_labels'] = bool(wasd['left']['labels'])
|
|
2876
|
+
tick_state['r_ticks'] = bool(wasd['right']['ticks'])
|
|
2877
|
+
tick_state['r_labels'] = bool(wasd['right']['labels'])
|
|
2878
|
+
# Legacy combined flags for backward compatibility
|
|
2879
|
+
tick_state['tx'] = bool(wasd['top']['ticks'] and wasd['top']['labels'])
|
|
2880
|
+
tick_state['bx'] = bool(wasd['bottom']['ticks'] and wasd['bottom']['labels'])
|
|
2881
|
+
tick_state['ly'] = bool(wasd['left']['ticks'] and wasd['left']['labels'])
|
|
2882
|
+
tick_state['ry'] = bool(wasd['right']['ticks'] and wasd['right']['labels'])
|
|
2883
|
+
# Minor ticks
|
|
2884
|
+
tick_state['mtx'] = bool(wasd['top']['minor'])
|
|
2885
|
+
tick_state['mbx'] = bool(wasd['bottom']['minor'])
|
|
2886
|
+
tick_state['mly'] = bool(wasd['left']['minor'])
|
|
2887
|
+
tick_state['mry'] = bool(wasd['right']['minor'])
|
|
2888
|
+
while True:
|
|
2889
|
+
print(_colorize_inline_commands("WASD toggles: direction (w/a/s/d) x action (1..5)"))
|
|
2890
|
+
print(_colorize_inline_commands(" 1=spine 2=ticks 3=minor ticks 4=tick labels 5=axis title"))
|
|
2891
|
+
print(_colorize_inline_commands("Type 'i' to invert tick direction, 'l' to change tick length, 'list' for state, 'q' to return."))
|
|
2892
|
+
print(_colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
|
|
2893
|
+
cmd = _safe_input(_colorize_prompt("t> ")).strip().lower()
|
|
2894
|
+
if not cmd:
|
|
2895
|
+
continue
|
|
2896
|
+
if cmd == 'q':
|
|
2897
|
+
break
|
|
2898
|
+
if cmd == 'i':
|
|
2899
|
+
# Invert tick direction (toggle between 'out' and 'in')
|
|
2900
|
+
push_state("tick-direction")
|
|
2901
|
+
current_dir = getattr(fig, '_tick_direction', 'out')
|
|
2902
|
+
new_dir = 'in' if current_dir == 'out' else 'out'
|
|
2903
|
+
setattr(fig, '_tick_direction', new_dir)
|
|
2904
|
+
ax.tick_params(axis='both', which='both', direction=new_dir)
|
|
2905
|
+
print(f"Tick direction: {new_dir}")
|
|
2906
|
+
try:
|
|
2907
|
+
fig.canvas.draw()
|
|
2908
|
+
except Exception:
|
|
2909
|
+
fig.canvas.draw_idle()
|
|
2910
|
+
continue
|
|
2911
|
+
if cmd == 'p':
|
|
2912
|
+
_title_offset_menu()
|
|
2913
|
+
continue
|
|
2914
|
+
if cmd == 'l':
|
|
2915
|
+
# Change tick length (major and minor automatically set to 70%)
|
|
2916
|
+
try:
|
|
2917
|
+
# Get current major tick length from axes
|
|
2918
|
+
current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
|
|
2919
|
+
print(f"Current major tick length: {current_major}")
|
|
2920
|
+
new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
|
|
2921
|
+
if not new_length_str:
|
|
2922
|
+
continue
|
|
2923
|
+
new_major = float(new_length_str)
|
|
2924
|
+
if new_major <= 0:
|
|
2925
|
+
print("Length must be positive.")
|
|
2926
|
+
continue
|
|
2927
|
+
new_minor = new_major * 0.7 # Auto-set minor to 70%
|
|
2928
|
+
push_state("tick-length")
|
|
2929
|
+
# Apply to all four axes
|
|
2930
|
+
ax.tick_params(axis='both', which='major', length=new_major)
|
|
2931
|
+
ax.tick_params(axis='both', which='minor', length=new_minor)
|
|
2932
|
+
# Store for persistence
|
|
2933
|
+
if not hasattr(fig, '_tick_lengths'):
|
|
2934
|
+
fig._tick_lengths = {}
|
|
2935
|
+
fig._tick_lengths.update({'major': new_major, 'minor': new_minor})
|
|
2936
|
+
print(f"Set major tick length: {new_major}, minor: {new_minor:.2f}")
|
|
2937
|
+
try:
|
|
2938
|
+
fig.canvas.draw()
|
|
2939
|
+
except Exception:
|
|
2940
|
+
fig.canvas.draw_idle()
|
|
2941
|
+
except ValueError:
|
|
2942
|
+
print("Invalid number.")
|
|
2943
|
+
except Exception as e:
|
|
2944
|
+
print(f"Error setting tick length: {e}")
|
|
2945
|
+
continue
|
|
2946
|
+
if cmd == 'list':
|
|
2947
|
+
print(_colorize_inline_commands("Spine/ticks state:"))
|
|
2948
|
+
def b(v): return 'ON' if bool(v) else 'off'
|
|
2949
|
+
print(_colorize_inline_commands(f"top w1:{b(wasd['top']['spine'])} w2:{b(wasd['top']['ticks'])} w3:{b(wasd['top']['minor'])} w4:{b(wasd['top']['labels'])} w5:{b(wasd['top']['title'])}"))
|
|
2950
|
+
print(_colorize_inline_commands(f"bottom s1:{b(wasd['bottom']['spine'])} s2:{b(wasd['bottom']['ticks'])} s3:{b(wasd['bottom']['minor'])} s4:{b(wasd['bottom']['labels'])} s5:{b(wasd['bottom']['title'])}"))
|
|
2951
|
+
print(_colorize_inline_commands(f"left a1:{b(wasd['left']['spine'])} a2:{b(wasd['left']['ticks'])} a3:{b(wasd['left']['minor'])} a4:{b(wasd['left']['labels'])} a5:{b(wasd['left']['title'])}"))
|
|
2952
|
+
print(_colorize_inline_commands(f"right d1:{b(wasd['right']['spine'])} d2:{b(wasd['right']['ticks'])} d3:{b(wasd['right']['minor'])} d4:{b(wasd['right']['labels'])} d5:{b(wasd['right']['title'])}"))
|
|
2953
|
+
continue
|
|
2954
|
+
push_state("wasd-toggle")
|
|
2955
|
+
changed = False
|
|
2956
|
+
changed_sides = set() # Track which sides were affected
|
|
2957
|
+
for p in cmd.split():
|
|
2958
|
+
if len(p) != 2:
|
|
2959
|
+
print(f"Unknown code: {p}"); continue
|
|
2960
|
+
side = {'w':'top','a':'left','s':'bottom','d':'right'}.get(p[0])
|
|
2961
|
+
if side is None or p[1] not in '12345':
|
|
2962
|
+
print(f"Unknown code: {p}"); continue
|
|
2963
|
+
key = {'1':'spine','2':'ticks','3':'minor','4':'labels','5':'title'}[p[1]]
|
|
2964
|
+
wasd[side][key] = not bool(wasd[side][key])
|
|
2965
|
+
changed = True
|
|
2966
|
+
# Track which side was changed to only reposition affected sides
|
|
2967
|
+
# Labels and titles affect positioning, but spine/tick toggles don't necessarily
|
|
2968
|
+
if key in ('labels', 'title'):
|
|
2969
|
+
changed_sides.add(side)
|
|
2970
|
+
if changed:
|
|
2971
|
+
_sync_tick_state()
|
|
2972
|
+
_apply_wasd(changed_sides if changed_sides else None)
|
|
2973
|
+
_update_tick_visibility()
|
|
2974
|
+
# Single draw at the end after all positioning is complete
|
|
2975
|
+
try:
|
|
2976
|
+
fig.canvas.draw()
|
|
2977
|
+
except Exception:
|
|
2978
|
+
fig.canvas.draw_idle()
|
|
2979
|
+
except Exception as e:
|
|
2980
|
+
print(f"Error in WASD tick visibility menu: {e}")
|
|
2981
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
2982
|
+
continue
|
|
2983
|
+
elif key == 's':
|
|
2984
|
+
try:
|
|
2985
|
+
from .session import dump_ec_session
|
|
2986
|
+
last_session_path = getattr(fig, '_last_session_save_path', None)
|
|
2987
|
+
folder = choose_save_path(source_paths, purpose="EC session save")
|
|
2988
|
+
if not folder:
|
|
2989
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
2990
|
+
print(f"\nChosen path: {folder}")
|
|
2991
|
+
try:
|
|
2992
|
+
files = sorted([f for f in os.listdir(folder) if f.lower().endswith('.pkl')])
|
|
2993
|
+
except Exception:
|
|
2994
|
+
files = []
|
|
2995
|
+
if files:
|
|
2996
|
+
print("Existing .pkl files:")
|
|
2997
|
+
for i, f in enumerate(files, 1):
|
|
2998
|
+
filepath = os.path.join(folder, f)
|
|
2999
|
+
timestamp = _format_file_timestamp(filepath)
|
|
3000
|
+
if timestamp:
|
|
3001
|
+
print(f" {i}: {f} ({timestamp})")
|
|
3002
|
+
else:
|
|
3003
|
+
print(f" {i}: {f}")
|
|
3004
|
+
if last_session_path:
|
|
3005
|
+
prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
|
|
3006
|
+
else:
|
|
3007
|
+
prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
|
|
3008
|
+
choice = _safe_input(prompt).strip()
|
|
3009
|
+
if not choice or choice.lower() == 'q':
|
|
3010
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3011
|
+
if choice.lower() == 'o':
|
|
3012
|
+
# Overwrite last saved session
|
|
3013
|
+
if not last_session_path:
|
|
3014
|
+
print("No previous save found.")
|
|
3015
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3016
|
+
if not os.path.exists(last_session_path):
|
|
3017
|
+
print(f"Previous save file not found: {last_session_path}")
|
|
3018
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3019
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
|
|
3020
|
+
if yn != 'y':
|
|
3021
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3022
|
+
dump_ec_session(last_session_path, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
|
|
3023
|
+
print(f"Overwritten session to {last_session_path}")
|
|
3024
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3025
|
+
if choice.isdigit() and files:
|
|
3026
|
+
idx = int(choice)
|
|
3027
|
+
if 1 <= idx <= len(files):
|
|
3028
|
+
name = files[idx-1]
|
|
3029
|
+
yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
|
|
3030
|
+
if yn != 'y':
|
|
3031
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3032
|
+
target = os.path.join(folder, name)
|
|
3033
|
+
dump_ec_session(target, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
|
|
3034
|
+
fig._last_session_save_path = target
|
|
3035
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3036
|
+
else:
|
|
3037
|
+
print("Invalid number.")
|
|
3038
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3039
|
+
if choice.lower() != 'o':
|
|
3040
|
+
name = choice
|
|
3041
|
+
root, ext = os.path.splitext(name)
|
|
3042
|
+
if ext == '':
|
|
3043
|
+
name = name + '.pkl'
|
|
3044
|
+
target = name if os.path.isabs(name) else os.path.join(folder, name)
|
|
3045
|
+
if os.path.exists(target):
|
|
3046
|
+
yn = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
|
|
3047
|
+
if yn != 'y':
|
|
3048
|
+
_print_menu(len(all_cycles), is_dqdv); continue
|
|
3049
|
+
dump_ec_session(target, fig=fig, ax=ax, cycle_lines=cycle_lines, skip_confirm=True)
|
|
3050
|
+
fig._last_session_save_path = target
|
|
3051
|
+
except Exception as e:
|
|
3052
|
+
print(f"Save failed: {e}")
|
|
3053
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3054
|
+
continue
|
|
3055
|
+
elif key == 'c':
|
|
3056
|
+
# Show current palette if one is applied (this is informational only)
|
|
3057
|
+
# Note: Individual cycles may use different colors, so we can't show a single "current" palette
|
|
3058
|
+
print(f"Total cycles: {len(all_cycles)}")
|
|
3059
|
+
print("Enter one of:")
|
|
3060
|
+
print(_colorize_inline_commands(" - numbers: e.g. 1 5 10"))
|
|
3061
|
+
print(_colorize_inline_commands(" - mappings: e.g. 1 red 5 u3 OR 1:red 5:#00B006"))
|
|
3062
|
+
print(_colorize_inline_commands(" - numbers + palette: e.g. 1 5 10 viridis OR 1 5 10 3"))
|
|
3063
|
+
print(_colorize_inline_commands(" - all (optionally with palette): e.g. all OR all viridis OR all 3"))
|
|
3064
|
+
print("\nRecommended palettes for scientific publications:")
|
|
3065
|
+
rec_palettes = [
|
|
3066
|
+
("tab10", "Distinct, colorblind-friendly (default matplotlib)"),
|
|
3067
|
+
("Set2", "Soft, pastel colors for presentations"),
|
|
3068
|
+
("Dark2", "Bold, saturated colors for print"),
|
|
3069
|
+
("viridis", "Perceptually uniform (blue→yellow)"),
|
|
3070
|
+
("plasma", "Perceptually uniform (purple→yellow)"),
|
|
3071
|
+
]
|
|
3072
|
+
for idx, (name, desc) in enumerate(rec_palettes, 1):
|
|
3073
|
+
bar = palette_preview(name)
|
|
3074
|
+
print(f" {idx}. {name} - {desc}")
|
|
3075
|
+
if bar:
|
|
3076
|
+
print(f" {bar}")
|
|
3077
|
+
print(" (Enter palette name OR number)")
|
|
3078
|
+
user_colors = get_user_color_list(fig)
|
|
3079
|
+
if user_colors:
|
|
3080
|
+
print("\nSaved colors (use number or u# in mappings):")
|
|
3081
|
+
for idx, color in enumerate(user_colors, 1):
|
|
3082
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
3083
|
+
print("Type 'u' to edit saved colors before assigning.")
|
|
3084
|
+
line = _safe_input("Selection: ").strip()
|
|
3085
|
+
if not line:
|
|
3086
|
+
continue
|
|
3087
|
+
if line.lower() == 'u':
|
|
3088
|
+
manage_user_colors(fig)
|
|
3089
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3090
|
+
continue
|
|
3091
|
+
tokens = line.replace(',', ' ').split()
|
|
3092
|
+
mode, cycles, mapping, palette, use_all = _parse_cycle_tokens(tokens, fig)
|
|
3093
|
+
push_state("cycles/colors")
|
|
3094
|
+
|
|
3095
|
+
# Filter to existing cycles and report ignored
|
|
3096
|
+
if use_all:
|
|
3097
|
+
existing = list(all_cycles)
|
|
3098
|
+
ignored = []
|
|
3099
|
+
else:
|
|
3100
|
+
existing = []
|
|
3101
|
+
ignored = []
|
|
3102
|
+
for c in cycles:
|
|
3103
|
+
if c in cycle_lines:
|
|
3104
|
+
existing.append(c)
|
|
3105
|
+
else:
|
|
3106
|
+
ignored.append(c)
|
|
3107
|
+
if not existing and mode != 'numbers': # numbers mode can be empty too; handle below
|
|
3108
|
+
print("No valid cycles found.")
|
|
3109
|
+
# Update visibility
|
|
3110
|
+
if existing:
|
|
3111
|
+
_set_visible_cycles(cycle_lines, existing)
|
|
3112
|
+
else:
|
|
3113
|
+
# If nothing valid provided, keep current visibility
|
|
3114
|
+
print("No valid cycles provided; keeping current visibility.")
|
|
3115
|
+
|
|
3116
|
+
# Apply coloring by mode
|
|
3117
|
+
if mode == 'map' and mapping:
|
|
3118
|
+
# Keep only existing cycles in mapping
|
|
3119
|
+
mapping2 = {c: mapping[c] for c in existing if c in mapping}
|
|
3120
|
+
_apply_colors(cycle_lines, mapping2)
|
|
3121
|
+
if mapping2:
|
|
3122
|
+
print("Applied manual colors:")
|
|
3123
|
+
for cyc, col in mapping2.items():
|
|
3124
|
+
print(f" Cycle {cyc}: {color_block(col)} {col}")
|
|
3125
|
+
elif mode == 'palette' and existing:
|
|
3126
|
+
# ====================================================================
|
|
3127
|
+
# APPLY COLOR PALETTE TO ELECTROCHEMISTRY CYCLES
|
|
3128
|
+
# ====================================================================
|
|
3129
|
+
# This applies a colormap to selected cycles in EC mode (GC, CV, dQ/dV).
|
|
3130
|
+
#
|
|
3131
|
+
# HOW IT WORKS:
|
|
3132
|
+
# Similar to XY mode, but works with cycles instead of individual files.
|
|
3133
|
+
# Each cycle gets a different color sampled from the colormap.
|
|
3134
|
+
#
|
|
3135
|
+
# Example with 10 cycles and 'viridis':
|
|
3136
|
+
# Cycle 1 → dark purple
|
|
3137
|
+
# Cycle 2 → purple-blue
|
|
3138
|
+
# Cycle 3 → blue
|
|
3139
|
+
# ...
|
|
3140
|
+
# Cycle 10 → bright yellow
|
|
3141
|
+
#
|
|
3142
|
+
# This creates a visual progression showing how the battery changes
|
|
3143
|
+
# over multiple cycles (degradation, capacity fade, etc.)
|
|
3144
|
+
# ====================================================================
|
|
3145
|
+
|
|
3146
|
+
# Special handling for Tab10 (default palette) to match hardcoded colors exactly
|
|
3147
|
+
default_tab10_colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
|
3148
|
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf']
|
|
3149
|
+
|
|
3150
|
+
if palette and palette.lower() in ('tab10', '1'):
|
|
3151
|
+
# Use the exact hardcoded Tab10 colors to match default behavior
|
|
3152
|
+
n = len(existing)
|
|
3153
|
+
cols = [mcolors.to_rgba(default_tab10_colors[i % len(default_tab10_colors)])
|
|
3154
|
+
for i in range(n)]
|
|
3155
|
+
else:
|
|
3156
|
+
try:
|
|
3157
|
+
# Get the continuous colormap from matplotlib
|
|
3158
|
+
# This allows direct sampling without quantization
|
|
3159
|
+
cmap = cm.get_cmap(palette) if palette else None
|
|
3160
|
+
except Exception:
|
|
3161
|
+
cmap = None
|
|
3162
|
+
|
|
3163
|
+
if cmap is None:
|
|
3164
|
+
print(f"Unknown colormap '{palette}'.")
|
|
3165
|
+
cols = []
|
|
3166
|
+
else:
|
|
3167
|
+
# Get number of cycles to color
|
|
3168
|
+
n = len(existing)
|
|
3169
|
+
|
|
3170
|
+
# Sample colors from colormap at evenly spaced positions
|
|
3171
|
+
if n == 1:
|
|
3172
|
+
# Single cycle: use middle of colormap
|
|
3173
|
+
cols = [cmap(0.55)]
|
|
3174
|
+
elif n == 2:
|
|
3175
|
+
# Two cycles: use endpoints for maximum contrast
|
|
3176
|
+
cols = [cmap(0.15), cmap(0.85)]
|
|
3177
|
+
else:
|
|
3178
|
+
# Multiple cycles: sample evenly across colormap range
|
|
3179
|
+
# np.linspace(0.08, 0.88, n) creates n evenly spaced positions
|
|
3180
|
+
# Example with 5 cycles: [0.08, 0.28, 0.48, 0.68, 0.88]
|
|
3181
|
+
# Each position is passed to cmap() to get the color at that position
|
|
3182
|
+
cols = [cmap(t) for t in np.linspace(0.08, 0.88, n)]
|
|
3183
|
+
|
|
3184
|
+
if cols:
|
|
3185
|
+
# Apply colors to cycles
|
|
3186
|
+
# Create dictionary mapping cycle number to color
|
|
3187
|
+
# Then apply to all line objects for those cycles
|
|
3188
|
+
_apply_colors(cycle_lines, {c: col for c, col in zip(existing, cols)})
|
|
3189
|
+
try:
|
|
3190
|
+
preview = color_bar([mcolors.to_hex(col) for col in cols])
|
|
3191
|
+
except Exception:
|
|
3192
|
+
preview = ""
|
|
3193
|
+
if preview:
|
|
3194
|
+
palette_display = 'tab10 (default)' if palette and palette.lower() in ('tab10', '1') else palette
|
|
3195
|
+
print(f"Palette '{palette_display}' applied: {preview}")
|
|
3196
|
+
elif mode == 'numbers' and existing:
|
|
3197
|
+
# Do not change colors in numbers-only mode; only visibility changes.
|
|
3198
|
+
pass
|
|
3199
|
+
|
|
3200
|
+
# Reapply curve linewidth (in case it was set)
|
|
3201
|
+
_apply_curve_linewidth(fig, cycle_lines)
|
|
3202
|
+
|
|
3203
|
+
# Apply stored smooth settings to newly visible cycles (only in dQdV mode)
|
|
3204
|
+
if is_dqdv and hasattr(fig, '_dqdv_smooth_settings'):
|
|
3205
|
+
_apply_stored_smooth_settings(cycle_lines, fig)
|
|
3206
|
+
|
|
3207
|
+
# Rebuild legend and redraw
|
|
3208
|
+
_rebuild_legend(ax)
|
|
3209
|
+
_apply_nice_ticks()
|
|
3210
|
+
try:
|
|
3211
|
+
fig.canvas.draw()
|
|
3212
|
+
except Exception:
|
|
3213
|
+
fig.canvas.draw_idle()
|
|
3214
|
+
|
|
3215
|
+
if ignored:
|
|
3216
|
+
print("Ignored cycles:", ", ".join(str(c) for c in ignored))
|
|
3217
|
+
# Show the menu again after completing the command
|
|
3218
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3219
|
+
continue
|
|
3220
|
+
elif key == 'a':
|
|
3221
|
+
# X-axis submenu: number-of-ions vs capacity (not available in dQdV mode)
|
|
3222
|
+
if is_dqdv:
|
|
3223
|
+
print("Capacity/ion conversion is not available in dQ/dV mode.")
|
|
3224
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3225
|
+
continue
|
|
3226
|
+
# X-axis submenu: number-of-ions vs capacity
|
|
3227
|
+
while True:
|
|
3228
|
+
print("X-axis menu: n=number of ions, c=capacity, q=back")
|
|
3229
|
+
sub = _safe_input("X> ").strip().lower()
|
|
3230
|
+
if not sub:
|
|
3231
|
+
continue
|
|
3232
|
+
if sub == 'q':
|
|
3233
|
+
break
|
|
3234
|
+
if sub == 'n':
|
|
3235
|
+
print("Input the theoretical capacity per 1 active ion (mAh g^-1), e.g., 125")
|
|
3236
|
+
val = _safe_input("C_theoretical_per_ion: ").strip()
|
|
3237
|
+
try:
|
|
3238
|
+
c_th = float(val)
|
|
3239
|
+
if c_th <= 0:
|
|
3240
|
+
print("Theoretical capacity must be positive.")
|
|
3241
|
+
continue
|
|
3242
|
+
except Exception:
|
|
3243
|
+
print("Invalid number.")
|
|
3244
|
+
continue
|
|
3245
|
+
# Store original x-data once, then set new x = orig_x / c_th
|
|
3246
|
+
push_state("x=n(ions)")
|
|
3247
|
+
for ln in ax.lines:
|
|
3248
|
+
try:
|
|
3249
|
+
if not hasattr(ln, "_orig_xdata_gc"):
|
|
3250
|
+
x0 = np.asarray(ln.get_xdata(), dtype=float)
|
|
3251
|
+
setattr(ln, "_orig_xdata_gc", x0.copy())
|
|
3252
|
+
x_orig = getattr(ln, "_orig_xdata_gc")
|
|
3253
|
+
ln.set_xdata(x_orig / c_th)
|
|
3254
|
+
except Exception:
|
|
3255
|
+
continue
|
|
3256
|
+
# Construct label with proper mathtext for superscript
|
|
3257
|
+
# Configure mathtext fontset BEFORE setting the label to ensure consistency
|
|
3258
|
+
try:
|
|
3259
|
+
import matplotlib.pyplot as plt
|
|
3260
|
+
import matplotlib as mpl
|
|
3261
|
+
font_fam = plt.rcParams.get('font.sans-serif', [''])
|
|
3262
|
+
font_fam_str = font_fam[0] if isinstance(font_fam, list) and font_fam else ''
|
|
3263
|
+
|
|
3264
|
+
# Configure mathtext to use the same font family
|
|
3265
|
+
if font_fam_str:
|
|
3266
|
+
# Configure mathtext fontset to match the regular font
|
|
3267
|
+
# For Arial-like fonts, use dejavusans; for Times/STIX, use stix
|
|
3268
|
+
lf = font_fam_str.lower()
|
|
3269
|
+
if any(k in lf for k in ('stix', 'times', 'roman')):
|
|
3270
|
+
mpl.rcParams['mathtext.fontset'] = 'stix'
|
|
3271
|
+
else:
|
|
3272
|
+
# Use dejavusans for Arial, Helvetica, etc. (closest match to Arial)
|
|
3273
|
+
mpl.rcParams['mathtext.fontset'] = 'dejavusans'
|
|
3274
|
+
mpl.rcParams['mathtext.default'] = 'regular'
|
|
3275
|
+
except Exception:
|
|
3276
|
+
pass
|
|
3277
|
+
|
|
3278
|
+
label_text = f"Number of ions (C / {c_th:g} mAh g$^{{-1}}$)"
|
|
3279
|
+
ax.set_xlabel(label_text)
|
|
3280
|
+
|
|
3281
|
+
# Apply current font settings to the label to ensure consistency
|
|
3282
|
+
try:
|
|
3283
|
+
import matplotlib.pyplot as plt
|
|
3284
|
+
font_fam = plt.rcParams.get('font.sans-serif', [''])
|
|
3285
|
+
font_fam_str = font_fam[0] if isinstance(font_fam, list) and font_fam else ''
|
|
3286
|
+
font_size = plt.rcParams.get('font.size', None)
|
|
3287
|
+
if font_fam_str:
|
|
3288
|
+
ax.xaxis.label.set_family(font_fam_str)
|
|
3289
|
+
if font_size is not None:
|
|
3290
|
+
ax.xaxis.label.set_size(font_size)
|
|
3291
|
+
# Force label to re-render with updated mathtext fontset by updating the text
|
|
3292
|
+
ax.set_xlabel(label_text)
|
|
3293
|
+
except Exception:
|
|
3294
|
+
pass
|
|
3295
|
+
_apply_nice_ticks()
|
|
3296
|
+
try:
|
|
3297
|
+
ax.relim(); ax.autoscale_view()
|
|
3298
|
+
except Exception:
|
|
3299
|
+
pass
|
|
3300
|
+
try:
|
|
3301
|
+
fig.canvas.draw()
|
|
3302
|
+
except Exception:
|
|
3303
|
+
fig.canvas.draw_idle()
|
|
3304
|
+
elif sub == 'c':
|
|
3305
|
+
# Restore original capacity on x if available
|
|
3306
|
+
push_state("x=capacity")
|
|
3307
|
+
any_restored = False
|
|
3308
|
+
for ln in ax.lines:
|
|
3309
|
+
try:
|
|
3310
|
+
if hasattr(ln, "_orig_xdata_gc"):
|
|
3311
|
+
x_orig = getattr(ln, "_orig_xdata_gc")
|
|
3312
|
+
ln.set_xdata(x_orig)
|
|
3313
|
+
any_restored = True
|
|
3314
|
+
except Exception:
|
|
3315
|
+
continue
|
|
3316
|
+
# Construct label with proper mathtext for superscript
|
|
3317
|
+
# Configure mathtext fontset BEFORE setting the label to ensure consistency
|
|
3318
|
+
try:
|
|
3319
|
+
import matplotlib.pyplot as plt
|
|
3320
|
+
import matplotlib as mpl
|
|
3321
|
+
font_fam = plt.rcParams.get('font.sans-serif', [''])
|
|
3322
|
+
font_fam_str = font_fam[0] if isinstance(font_fam, list) and font_fam else ''
|
|
3323
|
+
|
|
3324
|
+
# Configure mathtext to use the same font family
|
|
3325
|
+
if font_fam_str:
|
|
3326
|
+
# Configure mathtext fontset to match the regular font
|
|
3327
|
+
# For Arial-like fonts, use dejavusans; for Times/STIX, use stix
|
|
3328
|
+
lf = font_fam_str.lower()
|
|
3329
|
+
if any(k in lf for k in ('stix', 'times', 'roman')):
|
|
3330
|
+
mpl.rcParams['mathtext.fontset'] = 'stix'
|
|
3331
|
+
else:
|
|
3332
|
+
# Use dejavusans for Arial, Helvetica, etc. (closest match to Arial)
|
|
3333
|
+
mpl.rcParams['mathtext.fontset'] = 'dejavusans'
|
|
3334
|
+
mpl.rcParams['mathtext.default'] = 'regular'
|
|
3335
|
+
except Exception:
|
|
3336
|
+
pass
|
|
3337
|
+
|
|
3338
|
+
label_text = "Specific Capacity (mAh g$^{{-1}}$)"
|
|
3339
|
+
ax.set_xlabel(label_text)
|
|
3340
|
+
|
|
3341
|
+
# Apply current font settings to the label to ensure consistency
|
|
3342
|
+
try:
|
|
3343
|
+
import matplotlib.pyplot as plt
|
|
3344
|
+
font_fam = plt.rcParams.get('font.sans-serif', [''])
|
|
3345
|
+
font_fam_str = font_fam[0] if isinstance(font_fam, list) and font_fam else ''
|
|
3346
|
+
font_size = plt.rcParams.get('font.size', None)
|
|
3347
|
+
if font_fam_str:
|
|
3348
|
+
ax.xaxis.label.set_family(font_fam_str)
|
|
3349
|
+
if font_size is not None:
|
|
3350
|
+
ax.xaxis.label.set_size(font_size)
|
|
3351
|
+
# Force label to re-render with updated mathtext fontset by updating the text
|
|
3352
|
+
ax.set_xlabel(label_text)
|
|
3353
|
+
except Exception:
|
|
3354
|
+
pass
|
|
3355
|
+
if any_restored:
|
|
3356
|
+
_apply_nice_ticks()
|
|
3357
|
+
try:
|
|
3358
|
+
ax.relim(); ax.autoscale_view()
|
|
3359
|
+
except Exception:
|
|
3360
|
+
pass
|
|
3361
|
+
try:
|
|
3362
|
+
fig.canvas.draw()
|
|
3363
|
+
except Exception:
|
|
3364
|
+
fig.canvas.draw_idle()
|
|
3365
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3366
|
+
continue
|
|
3367
|
+
elif key == 'f':
|
|
3368
|
+
# Font submenu with numbered options
|
|
3369
|
+
cur_family = plt.rcParams.get('font.sans-serif', [''])[0]
|
|
3370
|
+
cur_size = plt.rcParams.get('font.size', None)
|
|
3371
|
+
while True:
|
|
3372
|
+
print(f"\nFont menu (current: family='{cur_family}', size={cur_size}): f=font family, s=size, q=back")
|
|
3373
|
+
sub = _safe_input("Font> ").strip().lower()
|
|
3374
|
+
if not sub:
|
|
3375
|
+
continue
|
|
3376
|
+
if sub == 'q':
|
|
3377
|
+
break
|
|
3378
|
+
if sub == 'f':
|
|
3379
|
+
# Common font families with numbered options
|
|
3380
|
+
fonts = ['Arial', 'DejaVu Sans', 'Helvetica', 'Liberation Sans',
|
|
3381
|
+
'Times New Roman', 'Courier New', 'Verdana', 'Tahoma']
|
|
3382
|
+
print("\nCommon font families:")
|
|
3383
|
+
for i, font in enumerate(fonts, 1):
|
|
3384
|
+
print(f" {i}: {font}")
|
|
3385
|
+
print("Or enter custom font name directly.")
|
|
3386
|
+
choice = _safe_input(f"Font family (current: '{cur_family}', number or name): ").strip()
|
|
3387
|
+
if not choice:
|
|
3388
|
+
continue
|
|
3389
|
+
# Check if it's a number
|
|
3390
|
+
if choice.isdigit():
|
|
3391
|
+
idx = int(choice)
|
|
3392
|
+
if 1 <= idx <= len(fonts):
|
|
3393
|
+
fam = fonts[idx-1]
|
|
3394
|
+
push_state("font-family")
|
|
3395
|
+
_apply_font_family(ax, fam)
|
|
3396
|
+
_rebuild_legend(ax)
|
|
3397
|
+
print(f"Applied font family: {fam}")
|
|
3398
|
+
try:
|
|
3399
|
+
fig.canvas.draw()
|
|
3400
|
+
except Exception:
|
|
3401
|
+
fig.canvas.draw_idle()
|
|
3402
|
+
else:
|
|
3403
|
+
print("Invalid number.")
|
|
3404
|
+
else:
|
|
3405
|
+
# Use as custom font name
|
|
3406
|
+
push_state("font-family")
|
|
3407
|
+
_apply_font_family(ax, choice)
|
|
3408
|
+
_rebuild_legend(ax)
|
|
3409
|
+
print(f"Applied font family: {choice}")
|
|
3410
|
+
try:
|
|
3411
|
+
fig.canvas.draw()
|
|
3412
|
+
except Exception:
|
|
3413
|
+
fig.canvas.draw_idle()
|
|
3414
|
+
elif sub == 's':
|
|
3415
|
+
# Show current size and accept direct input
|
|
3416
|
+
import matplotlib as mpl
|
|
3417
|
+
cur_size = mpl.rcParams.get('font.size', None)
|
|
3418
|
+
choice = _safe_input(f"Font size (current: {cur_size}): ").strip()
|
|
3419
|
+
if not choice:
|
|
3420
|
+
continue
|
|
3421
|
+
try:
|
|
3422
|
+
sz = float(choice)
|
|
3423
|
+
if sz > 0:
|
|
3424
|
+
push_state("font-size")
|
|
3425
|
+
_apply_font_size(ax, sz)
|
|
3426
|
+
_rebuild_legend(ax)
|
|
3427
|
+
print(f"Applied font size: {sz}")
|
|
3428
|
+
try:
|
|
3429
|
+
fig.canvas.draw()
|
|
3430
|
+
except Exception:
|
|
3431
|
+
fig.canvas.draw_idle()
|
|
3432
|
+
else:
|
|
3433
|
+
print("Size must be positive.")
|
|
3434
|
+
except Exception:
|
|
3435
|
+
print("Invalid size.")
|
|
3436
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3437
|
+
continue
|
|
3438
|
+
elif key == 'x':
|
|
3439
|
+
# X-axis: set limits only
|
|
3440
|
+
while True:
|
|
3441
|
+
current_xlim = ax.get_xlim()
|
|
3442
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
3443
|
+
lim = _safe_input("Set X limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
|
|
3444
|
+
if not lim or lim.lower() == 'q':
|
|
3445
|
+
break
|
|
3446
|
+
if lim.lower() == 'a':
|
|
3447
|
+
# Auto: restore original range
|
|
3448
|
+
push_state("x-limits-auto")
|
|
3449
|
+
orig_xlim = getattr(ax, '_original_xlim', ax.get_xlim())
|
|
3450
|
+
ax.set_xlim(*orig_xlim)
|
|
3451
|
+
_apply_nice_ticks()
|
|
3452
|
+
try:
|
|
3453
|
+
ax.relim()
|
|
3454
|
+
ax.autoscale_view(scalex=True, scaley=False)
|
|
3455
|
+
except Exception:
|
|
3456
|
+
pass
|
|
3457
|
+
fig.canvas.draw()
|
|
3458
|
+
print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
3459
|
+
continue
|
|
3460
|
+
if lim.lower() == 'w':
|
|
3461
|
+
# Upper only: change upper limit, fix lower - stay in loop
|
|
3462
|
+
while True:
|
|
3463
|
+
current_xlim = ax.get_xlim()
|
|
3464
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
3465
|
+
val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
|
|
3466
|
+
if not val or val.lower() == 'q':
|
|
3467
|
+
break
|
|
3468
|
+
try:
|
|
3469
|
+
new_upper = float(val)
|
|
3470
|
+
except (ValueError, KeyboardInterrupt):
|
|
3471
|
+
print("Invalid value, ignored.")
|
|
3472
|
+
continue
|
|
3473
|
+
push_state("x-limits")
|
|
3474
|
+
ax.set_xlim(current_xlim[0], new_upper)
|
|
3475
|
+
_apply_nice_ticks()
|
|
3476
|
+
# Reapply legend position after axis change to prevent movement
|
|
3477
|
+
try:
|
|
3478
|
+
leg = ax.get_legend()
|
|
3479
|
+
if leg is not None and leg.get_visible():
|
|
3480
|
+
_apply_legend_position(fig, ax)
|
|
3481
|
+
except Exception:
|
|
3482
|
+
pass
|
|
3483
|
+
fig.canvas.draw()
|
|
3484
|
+
print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
3485
|
+
continue
|
|
3486
|
+
if lim.lower() == 's':
|
|
3487
|
+
# Lower only: change lower limit, fix upper - stay in loop
|
|
3488
|
+
while True:
|
|
3489
|
+
current_xlim = ax.get_xlim()
|
|
3490
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
3491
|
+
val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
|
|
3492
|
+
if not val or val.lower() == 'q':
|
|
3493
|
+
break
|
|
3494
|
+
try:
|
|
3495
|
+
new_lower = float(val)
|
|
3496
|
+
except (ValueError, KeyboardInterrupt):
|
|
3497
|
+
print("Invalid value, ignored.")
|
|
3498
|
+
continue
|
|
3499
|
+
push_state("x-limits")
|
|
3500
|
+
ax.set_xlim(new_lower, current_xlim[1])
|
|
3501
|
+
_apply_nice_ticks()
|
|
3502
|
+
# Reapply legend position after axis change to prevent movement
|
|
3503
|
+
try:
|
|
3504
|
+
leg = ax.get_legend()
|
|
3505
|
+
if leg is not None and leg.get_visible():
|
|
3506
|
+
_apply_legend_position(fig, ax)
|
|
3507
|
+
except Exception:
|
|
3508
|
+
pass
|
|
3509
|
+
fig.canvas.draw()
|
|
3510
|
+
print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
3511
|
+
continue
|
|
3512
|
+
try:
|
|
3513
|
+
lo, hi = map(float, lim.split())
|
|
3514
|
+
push_state("x-limits")
|
|
3515
|
+
ax.set_xlim(lo, hi)
|
|
3516
|
+
_apply_nice_ticks()
|
|
3517
|
+
fig.canvas.draw()
|
|
3518
|
+
except Exception:
|
|
3519
|
+
print("Invalid limits, ignored.")
|
|
3520
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3521
|
+
continue
|
|
3522
|
+
elif key == 'y':
|
|
3523
|
+
# Y-axis: set limits only
|
|
3524
|
+
while True:
|
|
3525
|
+
current_ylim = ax.get_ylim()
|
|
3526
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
3527
|
+
lim = _safe_input("Set Y limits (min max), w=upper only, s=lower only, a=auto (restore original), q=back: ").strip()
|
|
3528
|
+
if not lim or lim.lower() == 'q':
|
|
3529
|
+
break
|
|
3530
|
+
if lim.lower() == 'a':
|
|
3531
|
+
# Auto: restore original range
|
|
3532
|
+
push_state("y-limits-auto")
|
|
3533
|
+
orig_ylim = getattr(ax, '_original_ylim', ax.get_ylim())
|
|
3534
|
+
ax.set_ylim(*orig_ylim)
|
|
3535
|
+
_apply_nice_ticks()
|
|
3536
|
+
try:
|
|
3537
|
+
ax.relim()
|
|
3538
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
3539
|
+
except Exception:
|
|
3540
|
+
pass
|
|
3541
|
+
fig.canvas.draw()
|
|
3542
|
+
print(f"Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
3543
|
+
continue
|
|
3544
|
+
if lim.lower() == 'w':
|
|
3545
|
+
# Upper only: change upper limit, fix lower - stay in loop
|
|
3546
|
+
while True:
|
|
3547
|
+
current_ylim = ax.get_ylim()
|
|
3548
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
3549
|
+
val = _safe_input(f"Enter new upper Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
|
|
3550
|
+
if not val or val.lower() == 'q':
|
|
3551
|
+
break
|
|
3552
|
+
try:
|
|
3553
|
+
new_upper = float(val)
|
|
3554
|
+
except (ValueError, KeyboardInterrupt):
|
|
3555
|
+
print("Invalid value, ignored.")
|
|
3556
|
+
continue
|
|
3557
|
+
push_state("y-limits")
|
|
3558
|
+
ax.set_ylim(current_ylim[0], new_upper)
|
|
3559
|
+
_apply_nice_ticks()
|
|
3560
|
+
# Reapply legend position after axis change to prevent movement
|
|
3561
|
+
try:
|
|
3562
|
+
leg = ax.get_legend()
|
|
3563
|
+
if leg is not None and leg.get_visible():
|
|
3564
|
+
_apply_legend_position(fig, ax)
|
|
3565
|
+
except Exception:
|
|
3566
|
+
pass
|
|
3567
|
+
fig.canvas.draw()
|
|
3568
|
+
print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
3569
|
+
continue
|
|
3570
|
+
if lim.lower() == 's':
|
|
3571
|
+
# Lower only: change lower limit, fix upper - stay in loop
|
|
3572
|
+
while True:
|
|
3573
|
+
current_ylim = ax.get_ylim()
|
|
3574
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
3575
|
+
val = _safe_input(f"Enter new lower Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
|
|
3576
|
+
if not val or val.lower() == 'q':
|
|
3577
|
+
break
|
|
3578
|
+
try:
|
|
3579
|
+
new_lower = float(val)
|
|
3580
|
+
except (ValueError, KeyboardInterrupt):
|
|
3581
|
+
print("Invalid value, ignored.")
|
|
3582
|
+
continue
|
|
3583
|
+
push_state("y-limits")
|
|
3584
|
+
ax.set_ylim(new_lower, current_ylim[1])
|
|
3585
|
+
_apply_nice_ticks()
|
|
3586
|
+
# Reapply legend position after axis change to prevent movement
|
|
3587
|
+
try:
|
|
3588
|
+
leg = ax.get_legend()
|
|
3589
|
+
if leg is not None and leg.get_visible():
|
|
3590
|
+
_apply_legend_position(fig, ax)
|
|
3591
|
+
except Exception:
|
|
3592
|
+
pass
|
|
3593
|
+
fig.canvas.draw()
|
|
3594
|
+
print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
3595
|
+
continue
|
|
3596
|
+
try:
|
|
3597
|
+
lo, hi = map(float, lim.split())
|
|
3598
|
+
push_state("y-limits")
|
|
3599
|
+
ax.set_ylim(lo, hi)
|
|
3600
|
+
_apply_nice_ticks()
|
|
3601
|
+
fig.canvas.draw()
|
|
3602
|
+
except Exception:
|
|
3603
|
+
print("Invalid limits, ignored.")
|
|
3604
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3605
|
+
continue
|
|
3606
|
+
elif key == 'g':
|
|
3607
|
+
# Geometry submenu: plot frame vs canvas (scales moved to separate keys)
|
|
3608
|
+
while True:
|
|
3609
|
+
print("Geometry menu: p=plot frame size, c=canvas size, q=back")
|
|
3610
|
+
sub = _safe_input("Geom> ").strip().lower()
|
|
3611
|
+
if not sub:
|
|
3612
|
+
continue
|
|
3613
|
+
if sub == 'q':
|
|
3614
|
+
break
|
|
3615
|
+
if sub == 'p':
|
|
3616
|
+
# We don’t have y_data_list/labels here; pass minimal placeholders to keep API
|
|
3617
|
+
push_state("resize-frame")
|
|
3618
|
+
try:
|
|
3619
|
+
resize_plot_frame(fig, ax, [], [], type('Args', (), {'stack': False})(), _update_labels)
|
|
3620
|
+
except Exception as e:
|
|
3621
|
+
print(f"Error changing plot frame: {e}")
|
|
3622
|
+
elif sub == 'c':
|
|
3623
|
+
push_state("resize-canvas")
|
|
3624
|
+
try:
|
|
3625
|
+
resize_canvas(fig, ax)
|
|
3626
|
+
except Exception as e:
|
|
3627
|
+
print(f"Error changing canvas: {e}")
|
|
3628
|
+
try:
|
|
3629
|
+
_apply_nice_ticks()
|
|
3630
|
+
fig.canvas.draw()
|
|
3631
|
+
except Exception:
|
|
3632
|
+
fig.canvas.draw_idle()
|
|
3633
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3634
|
+
continue
|
|
3635
|
+
elif key == 'sm':
|
|
3636
|
+
# dQ/dV smoothing utilities (only available in dQdV mode)
|
|
3637
|
+
if not is_dqdv:
|
|
3638
|
+
print("Smoothing is only available in dQ/dV mode.")
|
|
3639
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
3640
|
+
continue
|
|
3641
|
+
while True:
|
|
3642
|
+
print("\n\033[1mdQ/dV Data Filtering (Neware method)\033[0m")
|
|
3643
|
+
print("Commands:")
|
|
3644
|
+
print(" a: apply voltage step filter (removes small ΔV points)")
|
|
3645
|
+
print(" d: DiffCap smooth (≥1 mV ΔV + Savitzky–Golay, order 3, window 9)")
|
|
3646
|
+
print(" o: remove outliers (removes abrupt dQ/dV spikes)")
|
|
3647
|
+
print(" r: reset to original data")
|
|
3648
|
+
print(" q: back to main menu")
|
|
3649
|
+
sub = _safe_input("sm> ").strip().lower()
|
|
3650
|
+
if not sub:
|
|
3651
|
+
continue
|
|
3652
|
+
if sub == 'q':
|
|
3653
|
+
break
|
|
3654
|
+
if sub == 'r':
|
|
3655
|
+
push_state("smooth-reset")
|
|
3656
|
+
restored_count = 0
|
|
3657
|
+
try:
|
|
3658
|
+
for cyc, parts in cycle_lines.items():
|
|
3659
|
+
for role in ("charge", "discharge"):
|
|
3660
|
+
ln = parts.get(role) if isinstance(parts, dict) else parts
|
|
3661
|
+
if ln is None:
|
|
3662
|
+
continue
|
|
3663
|
+
if hasattr(ln, '_original_xdata'):
|
|
3664
|
+
ln.set_xdata(ln._original_xdata)
|
|
3665
|
+
ln.set_ydata(ln._original_ydata)
|
|
3666
|
+
# Clear smooth flag so smooth can be reapplied if needed
|
|
3667
|
+
if hasattr(ln, '_smooth_applied'):
|
|
3668
|
+
delattr(ln, '_smooth_applied')
|
|
3669
|
+
restored_count += 1
|
|
3670
|
+
if restored_count:
|
|
3671
|
+
print(f"Reset {restored_count} curve(s) to original data.")
|
|
3672
|
+
# Clear stored smooth settings
|
|
3673
|
+
if hasattr(fig, '_dqdv_smooth_settings'):
|
|
3674
|
+
fig._dqdv_smooth_settings = {}
|
|
3675
|
+
fig.canvas.draw_idle()
|
|
3676
|
+
else:
|
|
3677
|
+
print("No filtered data to reset.")
|
|
3678
|
+
except Exception as e:
|
|
3679
|
+
print(f"Error resetting filter: {e}")
|
|
3680
|
+
continue
|
|
3681
|
+
if sub == 'a':
|
|
3682
|
+
try:
|
|
3683
|
+
while True:
|
|
3684
|
+
threshold_input = _safe_input("Enter minimum voltage step in mV (default 0.5 mV, 'q'=quit, 'e'=explain): ").strip()
|
|
3685
|
+
if threshold_input.lower() == 'q':
|
|
3686
|
+
break
|
|
3687
|
+
if threshold_input.lower() == 'e':
|
|
3688
|
+
print("\n--- Voltage Step Filter Explanation ---")
|
|
3689
|
+
print("This filter removes data points where the voltage change (ΔV) between")
|
|
3690
|
+
print("consecutive points is smaller than the threshold.")
|
|
3691
|
+
print("\nExample: If threshold = 0.5 mV, any point where |V[i+1] - V[i]| < 0.5 mV")
|
|
3692
|
+
print("will be removed. This helps eliminate noisy or redundant measurements.")
|
|
3693
|
+
print("\nTypical values: 0.1-1.0 mV (smaller = more aggressive filtering)")
|
|
3694
|
+
print("Higher values remove more points but may oversmooth the data.")
|
|
3695
|
+
print("----------------------------------------\n")
|
|
3696
|
+
continue
|
|
3697
|
+
threshold_mv = 0.5 if not threshold_input else float(threshold_input)
|
|
3698
|
+
break
|
|
3699
|
+
if threshold_input.lower() == 'q': # User quit
|
|
3700
|
+
continue
|
|
3701
|
+
threshold_v = threshold_mv / 1000.0
|
|
3702
|
+
if threshold_v <= 0:
|
|
3703
|
+
print("Threshold must be positive.")
|
|
3704
|
+
continue
|
|
3705
|
+
push_state("smooth-apply")
|
|
3706
|
+
# Store smooth settings for future cycle changes
|
|
3707
|
+
if not hasattr(fig, '_dqdv_smooth_settings'):
|
|
3708
|
+
fig._dqdv_smooth_settings = {}
|
|
3709
|
+
fig._dqdv_smooth_settings.update({
|
|
3710
|
+
'method': 'voltage_step',
|
|
3711
|
+
'threshold_v': threshold_v
|
|
3712
|
+
})
|
|
3713
|
+
filtered = 0
|
|
3714
|
+
total_before = 0
|
|
3715
|
+
total_after = 0
|
|
3716
|
+
for cyc, parts in cycle_lines.items():
|
|
3717
|
+
for role in ("charge", "discharge"):
|
|
3718
|
+
ln = parts.get(role) if isinstance(parts, dict) else parts
|
|
3719
|
+
if ln is None or not ln.get_visible():
|
|
3720
|
+
continue
|
|
3721
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
3722
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
3723
|
+
if xdata.size < 3:
|
|
3724
|
+
continue
|
|
3725
|
+
if not hasattr(ln, '_original_xdata'):
|
|
3726
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
3727
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
3728
|
+
dv = np.abs(np.diff(xdata))
|
|
3729
|
+
mask = np.ones_like(xdata, dtype=bool)
|
|
3730
|
+
mask[1:] &= dv >= threshold_v
|
|
3731
|
+
mask[:-1] &= dv >= threshold_v
|
|
3732
|
+
filtered_x = xdata[mask]
|
|
3733
|
+
filtered_y = ydata[mask]
|
|
3734
|
+
before = len(xdata)
|
|
3735
|
+
after = len(filtered_x)
|
|
3736
|
+
if after < before:
|
|
3737
|
+
ln.set_xdata(filtered_x)
|
|
3738
|
+
ln.set_ydata(filtered_y)
|
|
3739
|
+
ln._smooth_applied = True
|
|
3740
|
+
filtered += 1
|
|
3741
|
+
total_before += before
|
|
3742
|
+
total_after += after
|
|
3743
|
+
if filtered:
|
|
3744
|
+
removed = total_before - total_after
|
|
3745
|
+
pct = 100 * removed / total_before if total_before else 0
|
|
3746
|
+
print(f"Filtered {filtered} curve(s); removed {removed} of {total_before} points ({pct:.1f}%).")
|
|
3747
|
+
print("Tip: Increase threshold to aggressively filter points (always applied to raw data).")
|
|
3748
|
+
fig.canvas.draw_idle()
|
|
3749
|
+
else:
|
|
3750
|
+
print("No curves affected by current threshold.")
|
|
3751
|
+
except ValueError:
|
|
3752
|
+
print("Invalid number.")
|
|
3753
|
+
continue
|
|
3754
|
+
if sub == 'd':
|
|
3755
|
+
try:
|
|
3756
|
+
print("DiffCap smoothing per Thompson et al. (2020): clean ΔV < threshold and apply Savitzky–Golay (order 3).")
|
|
3757
|
+
while True:
|
|
3758
|
+
delta_input = _safe_input("Minimum ΔV between points (mV, default 1.0, 'q'=quit, 'e'=explain): ").strip()
|
|
3759
|
+
if delta_input.lower() == 'q':
|
|
3760
|
+
break
|
|
3761
|
+
if delta_input.lower() == 'e':
|
|
3762
|
+
print("\n--- Minimum ΔV Explanation ---")
|
|
3763
|
+
print("First step: Remove points where voltage change is too small.")
|
|
3764
|
+
print("This threshold (in mV) determines the minimum voltage difference")
|
|
3765
|
+
print("required between consecutive points. Points with smaller ΔV are")
|
|
3766
|
+
print("removed as noise before smoothing.")
|
|
3767
|
+
print("\nTypical values: 0.5-2.0 mV")
|
|
3768
|
+
print("Smaller values = keep more points (less aggressive cleaning)")
|
|
3769
|
+
print("Larger values = remove more points (more aggressive cleaning)")
|
|
3770
|
+
print("--------------------------------\n")
|
|
3771
|
+
continue
|
|
3772
|
+
min_step = 0.001 if not delta_input else max(float(delta_input), 0.0) / 1000.0
|
|
3773
|
+
if min_step <= 0:
|
|
3774
|
+
print("ΔV threshold must be positive.")
|
|
3775
|
+
continue
|
|
3776
|
+
break
|
|
3777
|
+
# Only skip if user explicitly quit with 'q', not if they pressed Enter (empty = use default)
|
|
3778
|
+
if delta_input and delta_input.lower() == 'q': # User quit at previous step
|
|
3779
|
+
continue
|
|
3780
|
+
while True:
|
|
3781
|
+
window_input = _safe_input("Savitzky–Golay window (odd, default 9, 'q'=quit, 'e'=explain): ").strip()
|
|
3782
|
+
if window_input.lower() == 'q':
|
|
3783
|
+
break
|
|
3784
|
+
if window_input.lower() == 'e':
|
|
3785
|
+
print("\n--- Savitzky–Golay Window Explanation ---")
|
|
3786
|
+
print("The window size determines how many neighboring points are used")
|
|
3787
|
+
print("to smooth each data point. Must be an odd number (3, 5, 7, 9, 11, ...).")
|
|
3788
|
+
print("\nLarger window = smoother result but may lose fine details")
|
|
3789
|
+
print("Smaller window = preserves more detail but less smoothing")
|
|
3790
|
+
print("\nTypical values: 5-15 (9 is a good default)")
|
|
3791
|
+
print("Window must be larger than polynomial order.")
|
|
3792
|
+
print("------------------------------------------\n")
|
|
3793
|
+
continue
|
|
3794
|
+
window = 9 if not window_input else int(window_input)
|
|
3795
|
+
break
|
|
3796
|
+
# Only skip if user explicitly quit with 'q', not if they pressed Enter (empty = use default)
|
|
3797
|
+
if window_input and window_input.lower() == 'q': # User quit at previous step
|
|
3798
|
+
continue
|
|
3799
|
+
while True:
|
|
3800
|
+
poly_input = _safe_input("Polynomial order (default 3, 'q'=quit, 'e'=explain): ").strip()
|
|
3801
|
+
if poly_input.lower() == 'q':
|
|
3802
|
+
break
|
|
3803
|
+
if poly_input.lower() == 'e':
|
|
3804
|
+
print("\n--- Polynomial Order Explanation ---")
|
|
3805
|
+
print("The polynomial order determines the complexity of the smoothing")
|
|
3806
|
+
print("function. Higher order = more flexible curve fitting.")
|
|
3807
|
+
print("\nOrder 1 = linear (straight line) - very smooth, may oversimplify")
|
|
3808
|
+
print("Order 3 = cubic (default) - good balance of smoothness and detail")
|
|
3809
|
+
print("Order 5+ = higher complexity - preserves more features, less smooth")
|
|
3810
|
+
print("\nTypical values: 1-5 (3 is recommended)")
|
|
3811
|
+
print("Order must be less than window size.")
|
|
3812
|
+
print("--------------------------------------\n")
|
|
3813
|
+
continue
|
|
3814
|
+
poly = 3 if not poly_input else int(poly_input)
|
|
3815
|
+
break
|
|
3816
|
+
# Only skip if user explicitly quit with 'q', not if they pressed Enter (empty = use default)
|
|
3817
|
+
if poly_input and poly_input.lower() == 'q': # User quit at previous step
|
|
3818
|
+
continue
|
|
3819
|
+
except ValueError:
|
|
3820
|
+
print("Invalid number.")
|
|
3821
|
+
continue
|
|
3822
|
+
if window < 3:
|
|
3823
|
+
window = 3
|
|
3824
|
+
if window % 2 == 0:
|
|
3825
|
+
window += 1
|
|
3826
|
+
if poly < 1:
|
|
3827
|
+
poly = 1
|
|
3828
|
+
push_state("smooth-diffcap")
|
|
3829
|
+
# Store smooth settings for future cycle changes
|
|
3830
|
+
if not hasattr(fig, '_dqdv_smooth_settings'):
|
|
3831
|
+
fig._dqdv_smooth_settings = {}
|
|
3832
|
+
fig._dqdv_smooth_settings.update({
|
|
3833
|
+
'method': 'diffcap',
|
|
3834
|
+
'min_step': min_step,
|
|
3835
|
+
'window': window,
|
|
3836
|
+
'poly': poly
|
|
3837
|
+
})
|
|
3838
|
+
cleaned_curves = 0
|
|
3839
|
+
total_removed = 0
|
|
3840
|
+
for cyc, parts in cycle_lines.items():
|
|
3841
|
+
iter_parts = [(None, parts)] if not isinstance(parts, dict) else parts.items()
|
|
3842
|
+
for role, ln in iter_parts:
|
|
3843
|
+
if ln is None or not ln.get_visible():
|
|
3844
|
+
continue
|
|
3845
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
3846
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
3847
|
+
if xdata.size < 3:
|
|
3848
|
+
continue
|
|
3849
|
+
if not hasattr(ln, '_original_xdata'):
|
|
3850
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
3851
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
3852
|
+
x_clean, y_clean, removed = _diffcap_clean_series(xdata, ydata, min_step)
|
|
3853
|
+
if x_clean.size < poly + 2:
|
|
3854
|
+
continue
|
|
3855
|
+
y_smooth = _savgol_smooth(y_clean, window, poly)
|
|
3856
|
+
ln.set_xdata(x_clean)
|
|
3857
|
+
ln.set_ydata(y_smooth)
|
|
3858
|
+
ln._smooth_applied = True
|
|
3859
|
+
cleaned_curves += 1
|
|
3860
|
+
total_removed += removed
|
|
3861
|
+
if cleaned_curves:
|
|
3862
|
+
print(f"DiffCap smoothing applied to {cleaned_curves} curve(s); removed {total_removed} noisy points.")
|
|
3863
|
+
fig.canvas.draw_idle()
|
|
3864
|
+
else:
|
|
3865
|
+
print("No curves were smoothed (not enough data after cleaning).")
|
|
3866
|
+
continue
|
|
3867
|
+
if sub == 'o':
|
|
3868
|
+
print("Outlier removal methods:")
|
|
3869
|
+
print(" 1: Z-score (enter standard deviation threshold, default 5.0)")
|
|
3870
|
+
print(" 2: MAD (median absolute deviation, default factor 6.0)")
|
|
3871
|
+
while True:
|
|
3872
|
+
method = _safe_input("Method (1/2, blank=cancel, 'q'=quit, 'e'=explain): ").strip()
|
|
3873
|
+
if not method or method.lower() == 'q':
|
|
3874
|
+
break
|
|
3875
|
+
if method.lower() == 'e':
|
|
3876
|
+
print("\n--- Outlier Removal Methods Explanation ---")
|
|
3877
|
+
print("Method 1 - Z-score:")
|
|
3878
|
+
print(" Removes points where |(value - mean) / std| > threshold")
|
|
3879
|
+
print(" Works well for normally distributed data")
|
|
3880
|
+
print(" Default threshold: 5.0 (removes points >5 standard deviations)")
|
|
3881
|
+
print("\nMethod 2 - MAD (Median Absolute Deviation):")
|
|
3882
|
+
print(" Removes points where |(value - median) / MAD| > threshold")
|
|
3883
|
+
print(" More robust to outliers (uses median instead of mean)")
|
|
3884
|
+
print(" Default threshold: 6.0 (removes points >6 MAD units)")
|
|
3885
|
+
print("\nHigher threshold = removes fewer points (less aggressive)")
|
|
3886
|
+
print("Lower threshold = removes more points (more aggressive)")
|
|
3887
|
+
print("Typical thresholds: 3.0-10.0")
|
|
3888
|
+
print("--------------------------------------------\n")
|
|
3889
|
+
continue
|
|
3890
|
+
if method not in ('1', '2'):
|
|
3891
|
+
print("Unknown method.")
|
|
3892
|
+
continue
|
|
3893
|
+
break
|
|
3894
|
+
if not method: # User canceled/quitted
|
|
3895
|
+
continue
|
|
3896
|
+
try:
|
|
3897
|
+
while True:
|
|
3898
|
+
thresh_input = _safe_input("Enter threshold (blank=default, 'q'=quit, 'e'=explain): ").strip()
|
|
3899
|
+
if thresh_input.lower() == 'q':
|
|
3900
|
+
break
|
|
3901
|
+
if thresh_input.lower() == 'e':
|
|
3902
|
+
if method == '1':
|
|
3903
|
+
print("\n--- Z-score Threshold Explanation ---")
|
|
3904
|
+
print("Threshold determines how many standard deviations a point can")
|
|
3905
|
+
print("deviate from the mean before being considered an outlier.")
|
|
3906
|
+
print("\nDefault: 5.0 (removes points where |z-score| > 5)")
|
|
3907
|
+
print("Higher values (6-10) = remove only extreme outliers")
|
|
3908
|
+
print("Lower values (2-4) = remove more points, including moderate spikes")
|
|
3909
|
+
print("\nExample: threshold=5.0 means points >5σ from mean are removed")
|
|
3910
|
+
print("--------------------------------------\n")
|
|
3911
|
+
else:
|
|
3912
|
+
print("\n--- MAD Threshold Explanation ---")
|
|
3913
|
+
print("Threshold determines how many MAD units a point can deviate")
|
|
3914
|
+
print("from the median before being considered an outlier.")
|
|
3915
|
+
print("\nDefault: 6.0 (removes points where |MAD-score| > 6)")
|
|
3916
|
+
print("Higher values (7-10) = remove only extreme outliers")
|
|
3917
|
+
print("Lower values (3-5) = remove more points, including moderate spikes")
|
|
3918
|
+
print("\nMAD is more robust than standard deviation for noisy data.")
|
|
3919
|
+
print("----------------------------------\n")
|
|
3920
|
+
continue
|
|
3921
|
+
if method == '1':
|
|
3922
|
+
z_threshold = 5.0 if not thresh_input else float(thresh_input)
|
|
3923
|
+
if z_threshold <= 0:
|
|
3924
|
+
print("Threshold must be positive.")
|
|
3925
|
+
continue
|
|
3926
|
+
else:
|
|
3927
|
+
mad_threshold = 6.0 if not thresh_input else float(thresh_input)
|
|
3928
|
+
if mad_threshold <= 0:
|
|
3929
|
+
print("Threshold must be positive.")
|
|
3930
|
+
continue
|
|
3931
|
+
break
|
|
3932
|
+
# Only skip if user explicitly quit with 'q', not if they pressed Enter (empty = use default)
|
|
3933
|
+
if thresh_input and thresh_input.lower() == 'q': # User quit
|
|
3934
|
+
continue
|
|
3935
|
+
push_state("smooth-outlier")
|
|
3936
|
+
# Store smooth settings for future cycle changes
|
|
3937
|
+
if not hasattr(fig, '_dqdv_smooth_settings'):
|
|
3938
|
+
fig._dqdv_smooth_settings = {}
|
|
3939
|
+
thresh_val = z_threshold if method == '1' else mad_threshold
|
|
3940
|
+
fig._dqdv_smooth_settings.update({
|
|
3941
|
+
'method': 'outlier',
|
|
3942
|
+
'outlier_method': method,
|
|
3943
|
+
'threshold': thresh_val
|
|
3944
|
+
})
|
|
3945
|
+
filtered = 0
|
|
3946
|
+
total_before = 0
|
|
3947
|
+
total_after = 0
|
|
3948
|
+
for cyc, parts in cycle_lines.items():
|
|
3949
|
+
for role in ("charge", "discharge"):
|
|
3950
|
+
ln = parts.get(role) if isinstance(parts, dict) else parts
|
|
3951
|
+
if ln is None or not ln.get_visible():
|
|
3952
|
+
continue
|
|
3953
|
+
xdata = np.asarray(ln.get_xdata(), float)
|
|
3954
|
+
ydata = np.asarray(ln.get_ydata(), float)
|
|
3955
|
+
if xdata.size < 5:
|
|
3956
|
+
continue
|
|
3957
|
+
if not hasattr(ln, '_original_xdata'):
|
|
3958
|
+
ln._original_xdata = np.array(xdata, copy=True)
|
|
3959
|
+
ln._original_ydata = np.array(ydata, copy=True)
|
|
3960
|
+
if method == '1':
|
|
3961
|
+
mean_y = np.nanmean(ydata)
|
|
3962
|
+
std_y = np.nanstd(ydata)
|
|
3963
|
+
if not np.isfinite(std_y) or std_y == 0:
|
|
3964
|
+
continue
|
|
3965
|
+
zscores = np.abs((ydata - mean_y) / std_y)
|
|
3966
|
+
mask = zscores <= z_threshold
|
|
3967
|
+
else:
|
|
3968
|
+
median_y = np.nanmedian(ydata)
|
|
3969
|
+
mad = np.nanmedian(np.abs(ydata - median_y))
|
|
3970
|
+
if not np.isfinite(mad) or mad == 0:
|
|
3971
|
+
continue
|
|
3972
|
+
deviations = np.abs(ydata - median_y) / mad
|
|
3973
|
+
mask = deviations <= mad_threshold
|
|
3974
|
+
filtered_x = xdata[mask]
|
|
3975
|
+
filtered_y = ydata[mask]
|
|
3976
|
+
before = len(xdata)
|
|
3977
|
+
after = len(filtered_x)
|
|
3978
|
+
if after < before:
|
|
3979
|
+
ln.set_xdata(filtered_x)
|
|
3980
|
+
ln.set_ydata(filtered_y)
|
|
3981
|
+
ln._smooth_applied = True
|
|
3982
|
+
filtered += 1
|
|
3983
|
+
total_before += before
|
|
3984
|
+
total_after += after
|
|
3985
|
+
if filtered:
|
|
3986
|
+
removed = total_before - total_after
|
|
3987
|
+
pct = 100 * removed / total_before if total_before else 0
|
|
3988
|
+
method_name = "Z-score" if method == '1' else "MAD"
|
|
3989
|
+
print(f"Removed outliers from {filtered} curve(s) using {method_name} (threshold={thresh_val}).")
|
|
3990
|
+
print(f"Removed {removed} of {total_before} points ({pct:.1f}%).")
|
|
3991
|
+
print("Tip: Adjust threshold to control sensitivity (always applied to raw data).")
|
|
3992
|
+
fig.canvas.draw_idle()
|
|
3993
|
+
else:
|
|
3994
|
+
print("No outliers found with current threshold.")
|
|
3995
|
+
except ValueError:
|
|
3996
|
+
print("Invalid number.")
|
|
3997
|
+
continue
|
|
3998
|
+
print("Unknown command. Use a/o/r/q.")
|
|
3999
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
4000
|
+
continue
|
|
4001
|
+
else:
|
|
4002
|
+
print("Unknown command.")
|
|
4003
|
+
_print_menu(len(all_cycles), is_dqdv)
|
|
4004
|
+
|
|
4005
|
+
|
|
4006
|
+
def _get_geometry_snapshot(fig, ax) -> Dict:
|
|
4007
|
+
"""Collects a snapshot of geometry settings (axes labels and limits)."""
|
|
4008
|
+
return {
|
|
4009
|
+
'xlim': list(ax.get_xlim()),
|
|
4010
|
+
'ylim': list(ax.get_ylim()),
|
|
4011
|
+
'xlabel': ax.get_xlabel() or '',
|
|
4012
|
+
'ylabel': ax.get_ylabel() or '',
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
|
|
4016
|
+
def _get_style_snapshot(fig, ax, cycle_lines: Dict, tick_state: Dict) -> Dict:
|
|
4017
|
+
"""Collects a comprehensive snapshot of the current plot style (no curve data)."""
|
|
4018
|
+
# Figure and font properties
|
|
4019
|
+
fig_w, fig_h = fig.get_size_inches()
|
|
4020
|
+
ax_bbox = ax.get_position()
|
|
4021
|
+
frame_w_in = ax_bbox.width * fig_w
|
|
4022
|
+
frame_h_in = ax_bbox.height * fig_h
|
|
4023
|
+
|
|
4024
|
+
font_fam = plt.rcParams.get('font.sans-serif', [''])
|
|
4025
|
+
font_fam0 = font_fam[0] if font_fam else ''
|
|
4026
|
+
font_size = plt.rcParams.get('font.size')
|
|
4027
|
+
|
|
4028
|
+
# Spine properties
|
|
4029
|
+
spines = {}
|
|
4030
|
+
for name in ('bottom', 'top', 'left', 'right'):
|
|
4031
|
+
sp = ax.spines.get(name)
|
|
4032
|
+
if sp:
|
|
4033
|
+
spines[name] = {
|
|
4034
|
+
'linewidth': sp.get_linewidth(),
|
|
4035
|
+
'visible': sp.get_visible()
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
# Tick widths
|
|
4039
|
+
def _tick_width(axis_obj, which: str):
|
|
4040
|
+
try:
|
|
4041
|
+
tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
|
|
4042
|
+
width = tick_kw.get('width')
|
|
4043
|
+
if width is None:
|
|
4044
|
+
axis_name = getattr(axis_obj, 'axis_name', 'x')
|
|
4045
|
+
rc_key = f"{axis_name}tick.{which}.width"
|
|
4046
|
+
width = plt.rcParams.get(rc_key)
|
|
4047
|
+
if width is not None:
|
|
4048
|
+
return float(width)
|
|
4049
|
+
except Exception:
|
|
4050
|
+
return None
|
|
4051
|
+
return None
|
|
4052
|
+
|
|
4053
|
+
tick_widths = {
|
|
4054
|
+
'x_major': _tick_width(ax.xaxis, 'major'),
|
|
4055
|
+
'x_minor': _tick_width(ax.xaxis, 'minor'),
|
|
4056
|
+
'y_major': _tick_width(ax.yaxis, 'major'),
|
|
4057
|
+
'y_minor': _tick_width(ax.yaxis, 'minor'),
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
# Tick direction
|
|
4061
|
+
tick_direction = getattr(fig, '_tick_direction', 'out')
|
|
4062
|
+
|
|
4063
|
+
# Curve linewidth: get from stored value or first visible curve
|
|
4064
|
+
curve_linewidth = getattr(fig, '_ec_curve_linewidth', None)
|
|
4065
|
+
if curve_linewidth is None:
|
|
4066
|
+
try:
|
|
4067
|
+
for cyc, parts in cycle_lines.items():
|
|
4068
|
+
for role in ("charge", "discharge"):
|
|
4069
|
+
ln = parts.get(role)
|
|
4070
|
+
if ln is not None:
|
|
4071
|
+
try:
|
|
4072
|
+
curve_linewidth = float(ln.get_linewidth() or 1.0)
|
|
4073
|
+
break
|
|
4074
|
+
except Exception:
|
|
4075
|
+
pass
|
|
4076
|
+
if curve_linewidth is not None:
|
|
4077
|
+
break
|
|
4078
|
+
except Exception:
|
|
4079
|
+
pass
|
|
4080
|
+
if curve_linewidth is None:
|
|
4081
|
+
curve_linewidth = 1.0 # default
|
|
4082
|
+
|
|
4083
|
+
# Curve marker properties: get from first visible curve
|
|
4084
|
+
curve_marker_props = {}
|
|
4085
|
+
try:
|
|
4086
|
+
for cyc, role, ln in _iter_cycle_lines(cycle_lines):
|
|
4087
|
+
try:
|
|
4088
|
+
curve_marker_props = {
|
|
4089
|
+
'linestyle': ln.get_linestyle(),
|
|
4090
|
+
'marker': ln.get_marker(),
|
|
4091
|
+
'markersize': ln.get_markersize(),
|
|
4092
|
+
'markerfacecolor': ln.get_markerfacecolor(),
|
|
4093
|
+
'markeredgecolor': ln.get_markeredgecolor()
|
|
4094
|
+
}
|
|
4095
|
+
break
|
|
4096
|
+
except Exception:
|
|
4097
|
+
pass
|
|
4098
|
+
if curve_marker_props:
|
|
4099
|
+
break
|
|
4100
|
+
except Exception:
|
|
4101
|
+
pass
|
|
4102
|
+
|
|
4103
|
+
def _line_color_hex(ln):
|
|
4104
|
+
try:
|
|
4105
|
+
return mcolors.to_hex(ln.get_color())
|
|
4106
|
+
except Exception:
|
|
4107
|
+
col = ln.get_color()
|
|
4108
|
+
if isinstance(col, str):
|
|
4109
|
+
return col
|
|
4110
|
+
try:
|
|
4111
|
+
return mcolors.to_hex(mcolors.to_rgba(col))
|
|
4112
|
+
except Exception:
|
|
4113
|
+
return None
|
|
4114
|
+
|
|
4115
|
+
cycle_styles = {}
|
|
4116
|
+
for cyc, parts in cycle_lines.items():
|
|
4117
|
+
entry = {}
|
|
4118
|
+
if isinstance(parts, dict):
|
|
4119
|
+
for role in ("charge", "discharge"):
|
|
4120
|
+
ln = parts.get(role)
|
|
4121
|
+
if ln is None:
|
|
4122
|
+
continue
|
|
4123
|
+
style = {}
|
|
4124
|
+
color_hex = _line_color_hex(ln)
|
|
4125
|
+
if color_hex:
|
|
4126
|
+
style['color'] = color_hex
|
|
4127
|
+
style['visible'] = bool(ln.get_visible())
|
|
4128
|
+
if style:
|
|
4129
|
+
entry[role] = style
|
|
4130
|
+
else:
|
|
4131
|
+
ln = parts
|
|
4132
|
+
if ln is not None:
|
|
4133
|
+
style = {}
|
|
4134
|
+
color_hex = _line_color_hex(ln)
|
|
4135
|
+
if color_hex:
|
|
4136
|
+
style['color'] = color_hex
|
|
4137
|
+
style['visible'] = bool(ln.get_visible())
|
|
4138
|
+
if style:
|
|
4139
|
+
entry['line'] = style
|
|
4140
|
+
if entry:
|
|
4141
|
+
cycle_styles[str(cyc)] = entry
|
|
4142
|
+
|
|
4143
|
+
# Build WASD state (20 parameters) from current axes state
|
|
4144
|
+
def _get_spine_visible(which: str) -> bool:
|
|
4145
|
+
sp = ax.spines.get(which)
|
|
4146
|
+
try:
|
|
4147
|
+
return bool(sp.get_visible()) if sp is not None else False
|
|
4148
|
+
except Exception:
|
|
4149
|
+
return False
|
|
4150
|
+
|
|
4151
|
+
wasd_state = {
|
|
4152
|
+
'top': {
|
|
4153
|
+
'spine': _get_spine_visible('top'),
|
|
4154
|
+
'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
|
|
4155
|
+
'minor': bool(tick_state.get('mtx', False)),
|
|
4156
|
+
'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
|
|
4157
|
+
'title': bool(getattr(ax, '_top_xlabel_on', False))
|
|
4158
|
+
},
|
|
4159
|
+
'bottom': {
|
|
4160
|
+
'spine': _get_spine_visible('bottom'),
|
|
4161
|
+
'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
|
|
4162
|
+
'minor': bool(tick_state.get('mbx', False)),
|
|
4163
|
+
'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
|
|
4164
|
+
'title': bool(ax.get_xlabel())
|
|
4165
|
+
},
|
|
4166
|
+
'left': {
|
|
4167
|
+
'spine': _get_spine_visible('left'),
|
|
4168
|
+
'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
|
|
4169
|
+
'minor': bool(tick_state.get('mly', False)),
|
|
4170
|
+
'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
|
|
4171
|
+
'title': bool(ax.get_ylabel())
|
|
4172
|
+
},
|
|
4173
|
+
'right': {
|
|
4174
|
+
'spine': _get_spine_visible('right'),
|
|
4175
|
+
'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', False))),
|
|
4176
|
+
'minor': bool(tick_state.get('mry', False)),
|
|
4177
|
+
'labels': bool(tick_state.get('r_labels', tick_state.get('ry', False))),
|
|
4178
|
+
'title': bool(getattr(ax, '_right_ylabel_on', False))
|
|
4179
|
+
},
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
# Legend visibility/location
|
|
4183
|
+
legend_visible = False
|
|
4184
|
+
legend_xy_in = None
|
|
4185
|
+
try:
|
|
4186
|
+
leg = ax.get_legend()
|
|
4187
|
+
if leg is not None:
|
|
4188
|
+
legend_visible = bool(leg.get_visible())
|
|
4189
|
+
legend_xy_in = getattr(fig, '_ec_legend_xy_in', None)
|
|
4190
|
+
except Exception:
|
|
4191
|
+
pass
|
|
4192
|
+
|
|
4193
|
+
# Grid state
|
|
4194
|
+
grid_enabled = False
|
|
4195
|
+
try:
|
|
4196
|
+
# Check if grid is currently on by looking at gridline visibility
|
|
4197
|
+
for line in ax.get_xgridlines() + ax.get_ygridlines():
|
|
4198
|
+
if line.get_visible():
|
|
4199
|
+
grid_enabled = True
|
|
4200
|
+
break
|
|
4201
|
+
except Exception:
|
|
4202
|
+
grid_enabled = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
|
|
4203
|
+
|
|
4204
|
+
return {
|
|
4205
|
+
'kind': 'ec_style',
|
|
4206
|
+
'version': 2,
|
|
4207
|
+
'figure': {
|
|
4208
|
+
'canvas_size': [fig_w, fig_h],
|
|
4209
|
+
'frame_size': [frame_w_in, frame_h_in],
|
|
4210
|
+
'axes_fraction': [ax_bbox.x0, ax_bbox.y0, ax_bbox.width, ax_bbox.height],
|
|
4211
|
+
},
|
|
4212
|
+
'font': {'family': font_fam0, 'size': font_size},
|
|
4213
|
+
'legend': {
|
|
4214
|
+
'visible': legend_visible,
|
|
4215
|
+
'position_inches': legend_xy_in,
|
|
4216
|
+
'title': _get_legend_title(fig),
|
|
4217
|
+
},
|
|
4218
|
+
'spines': spines,
|
|
4219
|
+
'ticks': {'widths': tick_widths, 'direction': tick_direction},
|
|
4220
|
+
'grid': grid_enabled,
|
|
4221
|
+
'wasd_state': wasd_state,
|
|
4222
|
+
'title_offsets': {
|
|
4223
|
+
'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
4224
|
+
'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
4225
|
+
'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
4226
|
+
'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
4227
|
+
'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
4228
|
+
'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
4229
|
+
},
|
|
4230
|
+
'curve_linewidth': curve_linewidth,
|
|
4231
|
+
'curve_markers': curve_marker_props,
|
|
4232
|
+
'rotation_angle': getattr(fig, '_ec_rotation_angle', 0),
|
|
4233
|
+
'cycle_styles': cycle_styles,
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
|
|
4237
|
+
def _apply_cycle_styles(cycle_lines: Dict[int, Dict[str, Optional[object]]], style_cfg: Optional[Dict]) -> None:
|
|
4238
|
+
if not isinstance(style_cfg, dict):
|
|
4239
|
+
return
|
|
4240
|
+
for cyc_key, entry in style_cfg.items():
|
|
4241
|
+
try:
|
|
4242
|
+
cyc = int(cyc_key)
|
|
4243
|
+
except Exception:
|
|
4244
|
+
cyc = cyc_key
|
|
4245
|
+
if cyc not in cycle_lines:
|
|
4246
|
+
continue
|
|
4247
|
+
target = cycle_lines[cyc]
|
|
4248
|
+
if isinstance(target, dict):
|
|
4249
|
+
for role in ("charge", "discharge"):
|
|
4250
|
+
ln = target.get(role)
|
|
4251
|
+
style = entry.get(role) if isinstance(entry, dict) else None
|
|
4252
|
+
if ln is None or not isinstance(style, dict):
|
|
4253
|
+
continue
|
|
4254
|
+
if 'color' in style:
|
|
4255
|
+
try:
|
|
4256
|
+
ln.set_color(style['color'])
|
|
4257
|
+
except Exception:
|
|
4258
|
+
pass
|
|
4259
|
+
if 'visible' in style:
|
|
4260
|
+
try:
|
|
4261
|
+
ln.set_visible(bool(style['visible']))
|
|
4262
|
+
except Exception:
|
|
4263
|
+
pass
|
|
4264
|
+
else:
|
|
4265
|
+
ln = target
|
|
4266
|
+
style = None
|
|
4267
|
+
if isinstance(entry, dict):
|
|
4268
|
+
style = entry.get('line', entry)
|
|
4269
|
+
elif isinstance(entry, (list, tuple)):
|
|
4270
|
+
continue
|
|
4271
|
+
else:
|
|
4272
|
+
style = entry
|
|
4273
|
+
if ln is None or not isinstance(style, dict):
|
|
4274
|
+
continue
|
|
4275
|
+
if 'color' in style:
|
|
4276
|
+
try:
|
|
4277
|
+
ln.set_color(style['color'])
|
|
4278
|
+
except Exception:
|
|
4279
|
+
pass
|
|
4280
|
+
if 'visible' in style:
|
|
4281
|
+
try:
|
|
4282
|
+
ln.set_visible(bool(style['visible']))
|
|
4283
|
+
except Exception:
|
|
4284
|
+
pass
|
|
4285
|
+
|
|
4286
|
+
|
|
4287
|
+
def _print_style_snapshot(cfg: Dict):
|
|
4288
|
+
"""Prints the style configuration in a user-friendly format matching XY plot."""
|
|
4289
|
+
print("\n--- Style / Diagnostics ---")
|
|
4290
|
+
|
|
4291
|
+
# Geometry
|
|
4292
|
+
canvas_size = cfg.get('figure', {}).get('canvas_size', ['?', '?'])
|
|
4293
|
+
frame_size = cfg.get('figure', {}).get('frame_size', ['?', '?'])
|
|
4294
|
+
print(f"Figure size (inches): {canvas_size[0]:.3f} x {canvas_size[1]:.3f}")
|
|
4295
|
+
print(f"Plot frame size (inches): {frame_size[0]:.3f} x {frame_size[1]:.3f}")
|
|
4296
|
+
|
|
4297
|
+
# Font
|
|
4298
|
+
font = cfg.get('font', {})
|
|
4299
|
+
print(f"Effective font size (labels/ticks): {font.get('size', '?')}")
|
|
4300
|
+
print(f"Font family chain (rcParams['font.sans-serif']): ['{font.get('family', '?')}']")
|
|
4301
|
+
|
|
4302
|
+
# Legend state
|
|
4303
|
+
leg_cfg = cfg.get('legend', {})
|
|
4304
|
+
if leg_cfg:
|
|
4305
|
+
leg_vis = bool(leg_cfg.get('visible', False))
|
|
4306
|
+
leg_pos = leg_cfg.get('position_inches')
|
|
4307
|
+
if isinstance(leg_pos, (list, tuple)) and len(leg_pos) == 2:
|
|
4308
|
+
try:
|
|
4309
|
+
lx = float(leg_pos[0])
|
|
4310
|
+
ly = float(leg_pos[1])
|
|
4311
|
+
print(f"Legend: {'ON' if leg_vis else 'off'} at x={lx:.3f} in, y={ly:.3f} in (relative to canvas center)")
|
|
4312
|
+
except Exception:
|
|
4313
|
+
print(f"Legend: {'ON' if leg_vis else 'off'}; position stored but unreadable")
|
|
4314
|
+
else:
|
|
4315
|
+
print(f"Legend: {'ON' if leg_vis else 'off'}; position=auto")
|
|
4316
|
+
legend_title = leg_cfg.get('title')
|
|
4317
|
+
if legend_title:
|
|
4318
|
+
print(f"Legend title: {legend_title}")
|
|
4319
|
+
|
|
4320
|
+
# Rotation angle
|
|
4321
|
+
rotation_angle = cfg.get('rotation_angle', 0)
|
|
4322
|
+
if rotation_angle != 0:
|
|
4323
|
+
print(f"Rotation angle: {rotation_angle}°")
|
|
4324
|
+
|
|
4325
|
+
# Per-side matrix summary (spine, major, minor, labels, title)
|
|
4326
|
+
def _onoff(v):
|
|
4327
|
+
return 'ON ' if bool(v) else 'off'
|
|
4328
|
+
|
|
4329
|
+
wasd = cfg.get('wasd_state', {})
|
|
4330
|
+
if wasd:
|
|
4331
|
+
print("Per-side (w=top, a=left, s=bottom, d=right): spine, major, minor, labels, title")
|
|
4332
|
+
for side_key, side_label in [('top', 'w'), ('left', 'a'), ('bottom', 's'), ('right', 'd')]:
|
|
4333
|
+
s = wasd.get(side_key, {})
|
|
4334
|
+
spine_val = _onoff(s.get('spine', False))
|
|
4335
|
+
major_val = _onoff(s.get('ticks', False))
|
|
4336
|
+
minor_val = _onoff(s.get('minor', False))
|
|
4337
|
+
labels_val = _onoff(s.get('labels', False))
|
|
4338
|
+
title_val = _onoff(s.get('title', False))
|
|
4339
|
+
print(f" {side_label}1:{spine_val} {side_label}2:{major_val} {side_label}3:{minor_val} {side_label}4:{labels_val} {side_label}5:{title_val}")
|
|
4340
|
+
|
|
4341
|
+
# Tick widths
|
|
4342
|
+
tick_widths = cfg.get('ticks', {}).get('widths', {})
|
|
4343
|
+
x_maj = tick_widths.get('x_major')
|
|
4344
|
+
x_min = tick_widths.get('x_minor')
|
|
4345
|
+
y_maj = tick_widths.get('y_major')
|
|
4346
|
+
y_min = tick_widths.get('y_minor')
|
|
4347
|
+
print(f"Tick widths (major/minor): X=({x_maj}, {x_min}) Y=({y_maj}, {y_min})")
|
|
4348
|
+
|
|
4349
|
+
# Tick direction
|
|
4350
|
+
tick_direction = cfg.get('ticks', {}).get('direction', 'out')
|
|
4351
|
+
print(f"Tick direction: {tick_direction}")
|
|
4352
|
+
|
|
4353
|
+
# Grid
|
|
4354
|
+
grid_enabled = cfg.get('grid', False)
|
|
4355
|
+
print(f"Grid: {'enabled' if grid_enabled else 'disabled'}")
|
|
4356
|
+
|
|
4357
|
+
# Spines
|
|
4358
|
+
spines = cfg.get('spines', {})
|
|
4359
|
+
if spines:
|
|
4360
|
+
print("Spines:")
|
|
4361
|
+
for name in ('bottom', 'top', 'left', 'right'):
|
|
4362
|
+
props = spines.get(name, {})
|
|
4363
|
+
lw = props.get('linewidth', '?')
|
|
4364
|
+
vis = props.get('visible', False)
|
|
4365
|
+
col = props.get('color')
|
|
4366
|
+
print(f" {name:<6} lw={lw} visible={vis} color={col}")
|
|
4367
|
+
|
|
4368
|
+
# Curve linewidth
|
|
4369
|
+
curve_linewidth = cfg.get('curve_linewidth')
|
|
4370
|
+
if curve_linewidth is not None:
|
|
4371
|
+
print(f"Curve linewidth (all curves): {curve_linewidth:.3g}")
|
|
4372
|
+
|
|
4373
|
+
# Curve markers
|
|
4374
|
+
curve_markers = cfg.get('curve_markers', {})
|
|
4375
|
+
if curve_markers:
|
|
4376
|
+
ls = curve_markers.get('linestyle', '-')
|
|
4377
|
+
mk = curve_markers.get('marker', 'None')
|
|
4378
|
+
ms = curve_markers.get('markersize', 0)
|
|
4379
|
+
print(f"Curve style: linestyle={ls} marker={mk} markersize={ms}")
|
|
4380
|
+
|
|
4381
|
+
cycle_styles = cfg.get('cycle_styles', {})
|
|
4382
|
+
if cycle_styles:
|
|
4383
|
+
print("Cycle colors:")
|
|
4384
|
+
def _cycle_sort_key(key):
|
|
4385
|
+
try:
|
|
4386
|
+
return int(key)
|
|
4387
|
+
except Exception:
|
|
4388
|
+
return key
|
|
4389
|
+
for cyc_key in sorted(cycle_styles.keys(), key=_cycle_sort_key):
|
|
4390
|
+
entry = cycle_styles[cyc_key] or {}
|
|
4391
|
+
segments = []
|
|
4392
|
+
for role_label, role_key in (('charge', 'charge'), ('discharge', 'discharge'), ('line', 'line')):
|
|
4393
|
+
style = entry.get(role_key)
|
|
4394
|
+
if not isinstance(style, dict):
|
|
4395
|
+
continue
|
|
4396
|
+
color = style.get('color', 'unknown')
|
|
4397
|
+
vis = 'ON' if style.get('visible', True) else 'off'
|
|
4398
|
+
# Show color block for better visualization
|
|
4399
|
+
try:
|
|
4400
|
+
color_block_str = color_block(color) if color != 'unknown' else ''
|
|
4401
|
+
segments.append(f"{role_label}={color_block_str} {color} ({vis})")
|
|
4402
|
+
except Exception:
|
|
4403
|
+
segments.append(f"{role_label}={color} ({vis})")
|
|
4404
|
+
if segments:
|
|
4405
|
+
print(f" Cycle {cyc_key}: {', '.join(segments)}")
|
|
4406
|
+
|
|
4407
|
+
print("--- End diagnostics ---\n")
|
|
4408
|
+
|
|
4409
|
+
|
|
4410
|
+
def _export_style_dialog(cfg: Dict, default_ext: str = '.bpcfg', base_path: Optional[str] = None):
|
|
4411
|
+
"""Handles the dialog for exporting a style configuration to a file.
|
|
4412
|
+
|
|
4413
|
+
Args:
|
|
4414
|
+
cfg: Configuration dictionary to export
|
|
4415
|
+
default_ext: Default file extension ('.bps' for style-only, '.bpsg' for style+geometry)
|
|
4416
|
+
"""
|
|
4417
|
+
try:
|
|
4418
|
+
if base_path:
|
|
4419
|
+
print(f"\nChosen path: {base_path}")
|
|
4420
|
+
# List files with matching extension in Styles/ subdirectory
|
|
4421
|
+
file_list = list_files_in_subdirectory((default_ext, '.bpcfg'), 'style', base_path=base_path)
|
|
4422
|
+
bpcfg_files = [f[0] for f in file_list]
|
|
4423
|
+
if bpcfg_files:
|
|
4424
|
+
styles_root = base_path if base_path else os.getcwd()
|
|
4425
|
+
styles_dir = os.path.join(styles_root, 'Styles')
|
|
4426
|
+
print(f"Existing {default_ext} files in {styles_dir}:")
|
|
4427
|
+
for i, f in enumerate(bpcfg_files, 1):
|
|
4428
|
+
print(f" {i}: {f}")
|
|
4429
|
+
|
|
4430
|
+
choice = _safe_input(f"Export to file? Enter filename or number to overwrite (q=cancel): ").strip()
|
|
4431
|
+
if not choice or choice.lower() == 'q':
|
|
4432
|
+
return
|
|
4433
|
+
|
|
4434
|
+
target_path = ""
|
|
4435
|
+
if choice.isdigit() and bpcfg_files and 1 <= int(choice) <= len(bpcfg_files):
|
|
4436
|
+
target_path = file_list[int(choice) - 1][1] # Full path from list
|
|
4437
|
+
if not _confirm_overwrite(target_path):
|
|
4438
|
+
return
|
|
4439
|
+
else:
|
|
4440
|
+
# Add default extension if no extension provided
|
|
4441
|
+
if not any(choice.lower().endswith(ext) for ext in ['.bps', '.bpsg', '.bpcfg']):
|
|
4442
|
+
filename_with_ext = f"{choice}{default_ext}"
|
|
4443
|
+
else:
|
|
4444
|
+
filename_with_ext = choice
|
|
4445
|
+
|
|
4446
|
+
# Use organized path unless it's an absolute path
|
|
4447
|
+
if os.path.isabs(filename_with_ext):
|
|
4448
|
+
target_path = filename_with_ext
|
|
4449
|
+
else:
|
|
4450
|
+
target_path = get_organized_path(filename_with_ext, 'style', base_path=base_path)
|
|
4451
|
+
|
|
4452
|
+
if not _confirm_overwrite(target_path):
|
|
4453
|
+
return
|
|
4454
|
+
|
|
4455
|
+
with open(target_path, 'w', encoding='utf-8') as f:
|
|
4456
|
+
json.dump(cfg, f, indent=2)
|
|
4457
|
+
print(f"Style exported to {target_path}")
|
|
4458
|
+
return target_path
|
|
4459
|
+
|
|
4460
|
+
except Exception as e:
|
|
4461
|
+
print(f"Export failed: {e}")
|
|
4462
|
+
return None
|
|
4463
|
+
def _legend_no_frame(ax, *args, title: Optional[str] = None, **kwargs):
|
|
4464
|
+
leg = ax.legend(*args, **kwargs)
|
|
4465
|
+
if leg is not None:
|
|
4466
|
+
try:
|
|
4467
|
+
leg.set_frame_on(False)
|
|
4468
|
+
except Exception:
|
|
4469
|
+
pass
|
|
4470
|
+
if title:
|
|
4471
|
+
try:
|
|
4472
|
+
leg.set_title(title)
|
|
4473
|
+
except Exception:
|
|
4474
|
+
pass
|
|
4475
|
+
return leg
|
|
4476
|
+
|
|
4477
|
+
|
|
4478
|
+
def _apply_legend_position(fig, ax):
|
|
4479
|
+
xy_in = _sanitize_legend_offset(fig, getattr(fig, '_ec_legend_xy_in', None))
|
|
4480
|
+
if xy_in is None:
|
|
4481
|
+
return False
|
|
4482
|
+
# Preserve current title before rebuilding the legend
|
|
4483
|
+
_store_legend_title(fig, ax)
|
|
4484
|
+
handles, labels = _visible_legend_entries(ax)
|
|
4485
|
+
if not handles:
|
|
4486
|
+
return False
|
|
4487
|
+
fw, fh = fig.get_size_inches()
|
|
4488
|
+
if fw <= 0 or fh <= 0:
|
|
4489
|
+
return False
|
|
4490
|
+
fx = 0.5 + float(xy_in[0]) / float(fw)
|
|
4491
|
+
fy = 0.5 + float(xy_in[1]) / float(fh)
|
|
4492
|
+
_legend_no_frame(
|
|
4493
|
+
ax,
|
|
4494
|
+
handles,
|
|
4495
|
+
labels,
|
|
4496
|
+
loc='center',
|
|
4497
|
+
bbox_to_anchor=(fx, fy),
|
|
4498
|
+
bbox_transform=fig.transFigure,
|
|
4499
|
+
borderaxespad=1.0,
|
|
4500
|
+
title=_get_legend_title(fig),
|
|
4501
|
+
)
|
|
4502
|
+
return True
|
|
4503
|
+
|
|
4504
|
+
|
|
4505
|
+
def _sanitize_legend_offset(fig, xy):
|
|
4506
|
+
if xy is None or not isinstance(xy, (tuple, list)) or len(xy) != 2:
|
|
4507
|
+
return None
|
|
4508
|
+
try:
|
|
4509
|
+
x_val = float(xy[0])
|
|
4510
|
+
y_val = float(xy[1])
|
|
4511
|
+
except Exception:
|
|
4512
|
+
return None
|
|
4513
|
+
fw, fh = fig.get_size_inches()
|
|
4514
|
+
if fw <= 0 or fh <= 0:
|
|
4515
|
+
return None
|
|
4516
|
+
max_x = fw * 0.45
|
|
4517
|
+
max_y = fh * 0.45
|
|
4518
|
+
if abs(x_val) > max_x or abs(y_val) > max_y:
|
|
4519
|
+
return None
|
|
4520
|
+
return (x_val, y_val)
|