batplot 1.8.0__py3-none-any.whl → 1.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +5 -3
  3. batplot/batplot.py +44 -4
  4. batplot/cpc_interactive.py +96 -3
  5. batplot/electrochem_interactive.py +28 -0
  6. batplot/interactive.py +18 -2
  7. batplot/modes.py +12 -12
  8. batplot/operando.py +2 -0
  9. batplot/operando_ec_interactive.py +112 -11
  10. batplot/session.py +35 -1
  11. batplot/utils.py +40 -0
  12. batplot/version_check.py +85 -6
  13. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
  14. batplot-1.8.2.dist-info/RECORD +75 -0
  15. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.0.dist-info/RECORD +0 -52
  40. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.0.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.0.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)