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,2951 @@
1
+ """Session helpers for batplot interactive mode.
2
+
3
+ This module provides functions to save and load interactive plotting sessions.
4
+ Sessions allow you to save your current plot state (colors, labels, ranges, etc.)
5
+ and restore it later, so you don't have to recreate your styling from scratch.
6
+
7
+ HOW SESSIONS WORK:
8
+ -----------------
9
+ A session file (.pkl) contains a complete snapshot of your plot state:
10
+ - Data: Original x/y data, labels, offsets
11
+ - Styling: Colors, line widths, fonts, tick settings
12
+ - Geometry: Axis ranges, figure size, axes position
13
+ - State: Which curves are visible, label positions, etc.
14
+
15
+ When you save a session:
16
+ 1. All plot state is collected into a dictionary
17
+ 2. Dictionary is serialized using pickle (Python's object serialization)
18
+ 3. Saved to a .pkl file
19
+
20
+ When you load a session:
21
+ 1. .pkl file is read and deserialized
22
+ 2. Plot is recreated from saved data
23
+ 3. All styling and state is restored exactly as it was
24
+
25
+ This is different from style files (.bps/.bpsg):
26
+ - Style files: Save only styling (colors, fonts, ticks) - can be applied to different data
27
+ - Session files: Save everything including data - exact recreation of a specific plot
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import pickle
33
+ from typing import Any, Dict, Iterable, List, Sequence, Tuple
34
+
35
+ import os
36
+
37
+ import matplotlib.pyplot as plt
38
+ import numpy as np
39
+
40
+ from .utils import _confirm_overwrite
41
+ from .color_utils import ensure_colormap
42
+
43
+
44
+ def _current_tick_width(axis_obj, which: str):
45
+ """
46
+ Return the configured tick width for the given X/Y axis.
47
+
48
+ HOW IT WORKS:
49
+ ------------
50
+ Tick widths can be set in two places:
51
+ 1. **Per-axis setting**: Stored in axis object's internal dictionary
52
+ - This is set when you use ax.tick_params(axis='x', which='major', width=2.0)
53
+ - Stored in axis_obj._major_tick_kw or axis_obj._minor_tick_kw
54
+
55
+ 2. **Global matplotlib setting**: Stored in plt.rcParams
56
+ - This is the default used when per-axis setting isn't specified
57
+ - Key format: 'xtick.major.width' or 'ytick.minor.width'
58
+
59
+ This function checks both locations (per-axis first, then global) to find
60
+ the actual width being used.
61
+
62
+ Args:
63
+ axis_obj: Matplotlib axis object (ax.xaxis or ax.yaxis)
64
+ which: 'major' or 'minor' (which type of ticks)
65
+
66
+ Returns:
67
+ Tick width as float, or None if not found
68
+ """
69
+ try:
70
+ # Try to get width from axis object's internal settings
71
+ # _major_tick_kw and _minor_tick_kw are dictionaries storing tick parameters
72
+ tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
73
+ width = tick_kw.get('width') # Get 'width' key from dictionary
74
+
75
+ # If not found in axis object, check global matplotlib settings
76
+ if width is None:
77
+ # Get axis name ('x' or 'y') - defaults to 'x' if not found
78
+ axis_name = getattr(axis_obj, 'axis_name', 'x')
79
+ # Build rcParams key: 'xtick.major.width' or 'ytick.minor.width'
80
+ rc_key = f"{axis_name}tick.{which}.width"
81
+ width = plt.rcParams.get(rc_key) # Get from global settings
82
+
83
+ # Convert to float if found
84
+ if width is not None:
85
+ return float(width)
86
+ except Exception:
87
+ # If anything fails (attribute error, type error, etc.), return None
88
+ pass
89
+ return None
90
+
91
+
92
+ def _axis_label_text(ax, attr_name: str, getter):
93
+ """
94
+ Get axis label text, trying stored value first, then current label.
95
+
96
+ HOW IT WORKS:
97
+ ------------
98
+ Sometimes axis labels are hidden but we still want to know what they were.
99
+ This function tries two sources:
100
+
101
+ 1. **Stored attribute**: Check if label was saved in a special attribute
102
+ - Example: ax._stored_xlabel might contain "Voltage (V)" even if label is hidden
103
+ - This preserves the label text when labels are temporarily hidden
104
+
105
+ 2. **Current label**: Get text from the actual label object
106
+ - Example: ax.xaxis.label.get_text()
107
+ - This is the "live" label that's currently displayed
108
+
109
+ WHY TWO SOURCES?
110
+ ---------------
111
+ When labels are hidden (via WASD menu), the label object might be empty,
112
+ but we saved the text in a stored attribute. This function ensures we can
113
+ always retrieve the label text, even when hidden.
114
+
115
+ Args:
116
+ ax: Matplotlib axes object
117
+ attr_name: Name of stored attribute (e.g., '_stored_xlabel')
118
+ getter: Function to call to get current label (e.g., lambda: ax.get_xlabel())
119
+
120
+ Returns:
121
+ Label text string, or empty string if not found
122
+ """
123
+ # Try stored attribute first (preserves text even when label is hidden)
124
+ try:
125
+ stored = getattr(ax, attr_name)
126
+ if isinstance(stored, str) and stored:
127
+ return stored
128
+ except Exception:
129
+ pass
130
+
131
+ # Fallback: get from current label object
132
+ try:
133
+ return getter() or '' # getter() returns current label text
134
+ except Exception:
135
+ return ''
136
+
137
+
138
+ def _apply_axes_bbox(ax, bbox) -> bool:
139
+ """
140
+ Apply stored axes bounding box (position and size) to restore plot geometry.
141
+
142
+ HOW IT WORKS:
143
+ ------------
144
+ The bounding box (bbox) defines where the plot area is positioned within
145
+ the figure. It's stored as fractions (0.0 to 1.0) of the figure size.
146
+
147
+ COORDINATE SYSTEM:
148
+ -----------------
149
+ Figure coordinates (fractions):
150
+ - (0.0, 0.0) = bottom-left corner of figure
151
+ - (1.0, 1.0) = top-right corner of figure
152
+ - left, right, bottom, top are all between 0.0 and 1.0
153
+
154
+ Example bbox:
155
+ left=0.15, right=0.95, bottom=0.15, top=0.95
156
+ This means plot occupies 80% of figure width (0.95-0.15) and 80% of height,
157
+ centered with 15% margins on all sides.
158
+
159
+ CALCULATION:
160
+ -----------
161
+ - width = right - left (horizontal size)
162
+ - height = top - bottom (vertical size)
163
+ - Position = [left, bottom, width, height]
164
+
165
+ Args:
166
+ ax: Matplotlib axes object
167
+ bbox: Dictionary with keys 'left', 'right', 'bottom', 'top' (all floats 0.0-1.0)
168
+
169
+ Returns:
170
+ True if bbox was successfully applied, False if invalid or error occurred
171
+ """
172
+ # Validate input: must be a dictionary
173
+ if not isinstance(bbox, dict):
174
+ return False
175
+
176
+ # Check that all required keys are present
177
+ required = ('left', 'right', 'bottom', 'top')
178
+ if not all(k in bbox for k in required):
179
+ return False
180
+
181
+ try:
182
+ # Extract and convert to floats
183
+ left = float(bbox['left'])
184
+ right = float(bbox['right'])
185
+ bottom = float(bbox['bottom'])
186
+ top = float(bbox['top'])
187
+
188
+ # Calculate dimensions
189
+ width = right - left # Horizontal size
190
+ height = top - bottom # Vertical size
191
+
192
+ # Validate dimensions (must be positive)
193
+ if width <= 0 or height <= 0:
194
+ return False
195
+
196
+ # Apply position and size to axes
197
+ # set_position([left, bottom, width, height]) sets plot area within figure
198
+ ax.set_position([left, bottom, width, height])
199
+ return True
200
+ except Exception:
201
+ # If any conversion or application fails, return False
202
+ return False
203
+
204
+
205
+ def _get_primary_axis_label(ax, axis: str) -> str:
206
+ """Get primary axis label text, falling back to stored value when hidden."""
207
+ if axis == 'x':
208
+ label = ax.xaxis.label
209
+ stored_attr = '_stored_xlabel'
210
+ else:
211
+ label = ax.yaxis.label
212
+ stored_attr = '_stored_ylabel'
213
+ text = ''
214
+ try:
215
+ text = label.get_text() or ''
216
+ except Exception:
217
+ text = ''
218
+ if not text and hasattr(ax, stored_attr):
219
+ try:
220
+ stored = getattr(ax, stored_attr)
221
+ if stored:
222
+ text = stored
223
+ except Exception:
224
+ text = ''
225
+ return text or ''
226
+
227
+
228
+ def _get_duplicate_axis_label(ax, which: str, fallback: str = '') -> str:
229
+ """Get duplicate axis label (top/right) text."""
230
+ if which == 'top':
231
+ artist_attr = '_top_xlabel_artist'
232
+ override_attr = '_top_xlabel_text_override'
233
+ else:
234
+ artist_attr = '_right_ylabel_artist'
235
+ override_attr = '_right_ylabel_text_override'
236
+ if hasattr(ax, override_attr):
237
+ try:
238
+ override_val = getattr(ax, override_attr)
239
+ if override_val:
240
+ return override_val
241
+ except Exception:
242
+ pass
243
+ artist = getattr(ax, artist_attr, None)
244
+ if artist is not None and hasattr(artist, 'get_text'):
245
+ try:
246
+ txt = artist.get_text()
247
+ if txt:
248
+ return txt
249
+ except Exception:
250
+ pass
251
+ return fallback or ''
252
+
253
+
254
+ # ------------------------- Generic XY session (existing) -------------------------
255
+
256
+
257
+ def dump_session(
258
+ filename: str,
259
+ *,
260
+ fig,
261
+ ax,
262
+ x_data_list: Sequence[np.ndarray],
263
+ y_data_list: Sequence[np.ndarray],
264
+ orig_y: Sequence[np.ndarray],
265
+ offsets_list: Sequence[float],
266
+ labels: Sequence[str],
267
+ delta: float,
268
+ args,
269
+ tick_state: Dict[str, bool],
270
+ cif_tick_series: Iterable[Tuple[str, str, List[float], float | None, float, Any]] | None = None,
271
+ cif_hkl_map: Dict[str, List[Tuple[float, int, int, int]]] | None = None,
272
+ cif_hkl_label_map: Dict[str, Dict[float, str]] | None = None,
273
+ show_cif_hkl: bool | None = None,
274
+ show_cif_titles: bool | None = None,
275
+ skip_confirm: bool = False,
276
+ ) -> None:
277
+ """
278
+ Save current interactive session to a pickle file.
279
+
280
+ HOW SESSION SAVING WORKS:
281
+ ------------------------
282
+ This function captures the complete state of your interactive plot and
283
+ saves it to a .pkl file. When you load this file later, the plot will be
284
+ recreated exactly as it was, including:
285
+
286
+ - Data: All x/y data arrays, labels, file paths
287
+ - Styling: Colors, line widths, fonts, tick settings, spine properties
288
+ - Geometry: Figure size, axes position, axis ranges
289
+ - State: Which curves are visible, label positions, CIF overlays, etc.
290
+
291
+ SERIALIZATION PROCESS:
292
+ ---------------------
293
+ 1. Collect all plot state into a dictionary
294
+ 2. Infer axis mode from current labels (Q, 2theta, r, etc.)
295
+ 3. Capture figure/axes geometry (size, position)
296
+ 4. Save spine properties (linewidth, color, visibility)
297
+ 5. Save tick properties (widths, lengths, directions)
298
+ 6. Save axis labels (including duplicate top/right labels)
299
+ 7. Save curve properties (colors, linewidths, visibility)
300
+ 8. Serialize everything using pickle and write to file
301
+
302
+ WHY PICKLE?
303
+ -----------
304
+ Pickle is Python's built-in serialization format. It can save complex
305
+ Python objects (numpy arrays, matplotlib objects, etc.) to disk and
306
+ restore them exactly. This is perfect for saving complete plot state.
307
+
308
+ Args:
309
+ filename: Path to .pkl file where session will be saved
310
+ fig: Matplotlib figure object
311
+ ax: Matplotlib axes object
312
+ x_data_list: List of x-data arrays (one per curve)
313
+ y_data_list: List of y-data arrays (one per curve, with offsets applied)
314
+ orig_y: List of original y-data arrays (before offsets)
315
+ offsets_list: List of vertical offsets applied to each curve
316
+ labels: List of curve labels (file names or custom labels)
317
+ delta: Spacing between stacked curves (if stack mode)
318
+ args: Command-line arguments namespace (for axis mode, etc.)
319
+ tick_state: Dictionary of tick visibility states (top, bottom, left, right)
320
+ cif_tick_series: Optional CIF overlay data (for diffraction patterns)
321
+ cif_hkl_map: Optional CIF hkl indices mapping
322
+ cif_hkl_label_map: Optional CIF hkl label mapping
323
+ show_cif_hkl: Whether to show CIF hkl labels
324
+ show_cif_titles: Whether to show CIF titles
325
+ skip_confirm: If True, skip overwrite confirmation dialog
326
+ """
327
+
328
+ # Infer axis mode string
329
+ if getattr(args, 'xaxis', None) in ("Q", "2theta", "r", "energy", "k", "rft"):
330
+ axis_mode_session = args.xaxis
331
+ else:
332
+ # Best-effort inference from labels/units already set on axes
333
+ xl = (ax.get_xlabel() or "").lower()
334
+ if "q (" in xl:
335
+ axis_mode_session = "Q"
336
+ elif "$2\\theta$" in xl or "2" in xl and "theta" in xl:
337
+ axis_mode_session = "2theta"
338
+ elif xl.startswith("r ") or xl.startswith("r ("):
339
+ axis_mode_session = "r"
340
+ elif "energy" in xl:
341
+ axis_mode_session = "energy"
342
+ elif xl.startswith("k ") or xl.startswith("k ("):
343
+ axis_mode_session = "k"
344
+ elif "radial" in xl:
345
+ axis_mode_session = "rft"
346
+ else:
347
+ axis_mode_session = "unknown"
348
+
349
+ label_layout = 'stack' if getattr(args, 'stack', False) else 'block'
350
+
351
+ # Axes frame size (in inches) to complement the canvas size
352
+ bbox = ax.get_position()
353
+ fw, fh = fig.get_size_inches()
354
+ frame_w_in = bbox.width * fw
355
+ frame_h_in = bbox.height * fh
356
+
357
+ # Save spines state
358
+ spines_state = {
359
+ name: {
360
+ 'linewidth': sp.get_linewidth(),
361
+ 'color': sp.get_edgecolor(),
362
+ 'visible': sp.get_visible(),
363
+ } for name, sp in ax.spines.items()
364
+ }
365
+
366
+ # Helper to capture a representative tick line width
367
+ def _tick_width(axis, which: str):
368
+ return _current_tick_width(axis, which)
369
+
370
+ tick_widths = {
371
+ 'x_major': _tick_width(ax.xaxis, 'major'),
372
+ 'x_minor': _tick_width(ax.xaxis, 'minor'),
373
+ 'y_major': _tick_width(ax.yaxis, 'major'),
374
+ 'y_minor': _tick_width(ax.yaxis, 'minor'),
375
+ }
376
+
377
+ # Helper to get tick length
378
+ def _tick_length(axis, which):
379
+ try:
380
+ ticks = axis.get_major_ticks() if which=='major' else axis.get_minor_ticks()
381
+ for t in ticks:
382
+ ln = t.tick1line
383
+ if ln.get_visible():
384
+ return ln.get_markersize()
385
+ except Exception:
386
+ return None
387
+ return None
388
+
389
+ tick_lengths = {
390
+ 'x_major': _tick_length(ax.xaxis, 'major'),
391
+ 'x_minor': _tick_length(ax.xaxis, 'minor'),
392
+ 'y_major': _tick_length(ax.yaxis, 'major'),
393
+ 'y_minor': _tick_length(ax.yaxis, 'minor'),
394
+ }
395
+
396
+ sp = fig.subplotpars
397
+ subplot_margins = {
398
+ 'left': float(sp.left),
399
+ 'right': float(sp.right),
400
+ 'bottom': float(sp.bottom),
401
+ 'top': float(sp.top),
402
+ }
403
+
404
+ # Helper to capture WASD state
405
+ def _capture_wasd_state(axis):
406
+ ts = getattr(axis, '_saved_tick_state', {})
407
+ wasd = {}
408
+ for side in ('top', 'bottom', 'left', 'right'):
409
+ sp_obj = axis.spines.get(side)
410
+ prefix = {'top': 't', 'bottom': 'b', 'left': 'l', 'right': 'r'}[side]
411
+ # Consistent tick/title state logic for all sides
412
+ if side == 'left':
413
+ title_state = bool(axis.yaxis.label.get_visible())
414
+ elif side == 'bottom':
415
+ title_state = bool(axis.xaxis.label.get_visible())
416
+ elif side == 'top':
417
+ title_state = bool(getattr(axis, '_top_xlabel_on', False))
418
+ elif side == 'right':
419
+ title_state = bool(getattr(axis, '_right_ylabel_on', False))
420
+ else:
421
+ title_state = False
422
+ wasd[side] = {
423
+ 'spine': bool(sp_obj.get_visible() if sp_obj else False),
424
+ 'ticks': bool(ts.get(f'{prefix}_ticks', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
425
+ 'minor': bool(ts.get(f'm{prefix}x' if side in ('top','bottom') else f'm{prefix}y', False)),
426
+ 'labels': bool(ts.get(f'{prefix}_labels', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
427
+ 'title': title_state,
428
+ }
429
+ return wasd
430
+
431
+ wasd_state = _capture_wasd_state(ax)
432
+
433
+ try:
434
+ sess = {
435
+ 'version': 3,
436
+ 'x_data': [np.array(a) for a in x_data_list],
437
+ 'y_data': [np.array(a) for a in y_data_list],
438
+ 'orig_y': [np.array(a) for a in orig_y],
439
+ 'offsets': list(offsets_list),
440
+ 'labels': list(labels),
441
+ 'line_styles': [
442
+ {
443
+ 'color': ln.get_color(),
444
+ 'linewidth': ln.get_linewidth(),
445
+ 'linestyle': ln.get_linestyle(),
446
+ 'alpha': ln.get_alpha(),
447
+ 'marker': ln.get_marker(),
448
+ 'markersize': ln.get_markersize(),
449
+ 'markerfacecolor': ln.get_markerfacecolor(),
450
+ 'markeredgecolor': ln.get_markeredgecolor(),
451
+ } for ln in ax.lines
452
+ ],
453
+ 'delta': float(delta),
454
+ 'label_layout': label_layout,
455
+ 'axis_mode': axis_mode_session,
456
+ 'axis': {
457
+ 'xlabel': ax.get_xlabel(),
458
+ 'ylabel': ax.get_ylabel(),
459
+ 'xlim': ax.get_xlim(),
460
+ 'ylim': ax.get_ylim(),
461
+ 'norm_xlim': getattr(ax, '_norm_xlim', None), # x-range used for normalization
462
+ 'norm_ylim': getattr(ax, '_norm_ylim', None), # y-range used for normalization
463
+ },
464
+ 'figure': {
465
+ 'size': tuple(map(float, fig.get_size_inches())),
466
+ 'dpi': int(fig.dpi),
467
+ 'frame_size': (frame_w_in, frame_h_in),
468
+ 'axes_bbox': {
469
+ 'left': float(bbox.x0),
470
+ 'bottom': float(bbox.y0),
471
+ 'right': float(bbox.x0 + bbox.width),
472
+ 'top': float(bbox.y0 + bbox.height),
473
+ },
474
+ 'subplot_margins': subplot_margins,
475
+ 'spines': spines_state,
476
+ },
477
+ 'wasd_state': wasd_state,
478
+ 'tick_state': dict(tick_state),
479
+ 'tick_widths': tick_widths,
480
+ 'tick_lengths': tick_lengths,
481
+ 'font': {
482
+ 'size': plt.rcParams.get('font.size'),
483
+ 'chain': list(plt.rcParams.get('font.sans-serif', [])),
484
+ },
485
+ 'args_subset': {
486
+ 'stack': bool(getattr(args, 'stack', False)),
487
+ 'autoscale': bool(getattr(args, 'autoscale', False)),
488
+ 'norm': bool(getattr(args, 'norm', False)),
489
+ },
490
+ 'cif_tick_series': [tuple(t) for t in (cif_tick_series or [])],
491
+ 'cif_hkl_map': {k: [tuple(v) for v in val] for k, val in (cif_hkl_map or {}).items()},
492
+ 'cif_hkl_label_map': {k: dict(v) for k, v in (cif_hkl_label_map or {}).items()},
493
+ 'show_cif_hkl': bool(show_cif_hkl),
494
+ 'show_cif_titles': bool(show_cif_titles) if show_cif_titles is not None else True,
495
+ }
496
+ sess['axis_titles'] = {
497
+ 'top_x': bool(getattr(ax, '_top_xlabel_on', False)),
498
+ 'right_y': bool(getattr(ax, '_right_ylabel_on', False)),
499
+ 'has_bottom_x': bool(ax.xaxis.label.get_visible()),
500
+ 'has_left_y': bool(ax.yaxis.label.get_visible()),
501
+ }
502
+ sess['axis_title_texts'] = {
503
+ 'bottom_x': _get_primary_axis_label(ax, 'x'),
504
+ 'left_y': _get_primary_axis_label(ax, 'y'),
505
+ 'top_x': _get_duplicate_axis_label(ax, 'top', _get_primary_axis_label(ax, 'x')),
506
+ 'right_y': _get_duplicate_axis_label(ax, 'right', _get_primary_axis_label(ax, 'y')),
507
+ }
508
+ # Save curve names visibility
509
+ sess['curve_names_visible'] = bool(getattr(fig, '_curve_names_visible', True))
510
+ # Save stack/legend anchor preferences
511
+ sess['stack_label_at_bottom'] = bool(getattr(fig, '_stack_label_at_bottom', False))
512
+ sess['label_anchor_left'] = bool(getattr(fig, '_label_anchor_left', False))
513
+ # Save grid state
514
+ sess['grid'] = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
515
+ if skip_confirm:
516
+ target = filename
517
+ else:
518
+ target = _confirm_overwrite(filename)
519
+ if not target:
520
+ print("Session save canceled.")
521
+ return
522
+ # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
523
+ from .utils import ensure_exact_case_filename
524
+ target = ensure_exact_case_filename(target)
525
+
526
+ with open(target, 'wb') as f:
527
+ pickle.dump(sess, f)
528
+ print(f"Session saved to {target}")
529
+ except Exception as e: # pragma: no cover - defensive path
530
+ print(f"Error saving session: {e}")
531
+
532
+ # --------------------- Operando + EC combined session helpers --------------------
533
+
534
+ def dump_operando_session(
535
+ filename: str,
536
+ *,
537
+ fig,
538
+ ax, # operando axes
539
+ im, # AxesImage for operando
540
+ cbar, # Colorbar object
541
+ ec_ax=None,
542
+ skip_confirm: bool = False,
543
+ ) -> None:
544
+ """Serialize the current operando+EC interactive session to a pickle file.
545
+
546
+ Captures enough state to reconstruct the figure layout, operando image,
547
+ colorbar, and optional EC panel including ions-mode formatting.
548
+
549
+ Args:
550
+ skip_confirm: If True, skip overwrite confirmation (already handled by caller).
551
+ """
552
+ try:
553
+ # Figure & inches geometry
554
+ fig_w, fig_h = map(float, fig.get_size_inches())
555
+ dpi = int(fig.dpi)
556
+ # Layout in inches (group-centered on restore)
557
+ ax_x0, ax_y0, ax_wf, ax_hf = ax.get_position().bounds
558
+ cb_x0, cb_y0, cb_wf, cb_hf = cbar.ax.get_position().bounds
559
+ if ec_ax is not None:
560
+ ec_x0, ec_y0, ec_wf, ec_hf = ec_ax.get_position().bounds
561
+ else:
562
+ ec_x0 = ec_y0 = ec_wf = ec_hf = 0.0
563
+ # Prefer using fixed attributes if they exist (more reliable than calculating from positions)
564
+ cb_w_in = getattr(cbar.ax, '_fixed_cb_w_in', cb_wf * fig_w)
565
+ cb_gap_in = getattr(cbar.ax, '_fixed_cb_gap_in', (ax_x0 - (cb_x0 + cb_wf)) * fig_w)
566
+ ax_w_in = getattr(ax, '_fixed_ax_w_in', ax_wf * fig_w)
567
+ ax_h_in = getattr(ax, '_fixed_ax_h_in', ax_hf * fig_h)
568
+ if ec_ax is not None:
569
+ ec_gap_in = getattr(ec_ax, '_fixed_ec_gap_in', (ec_x0 - (ax_x0 + ax_wf)) * fig_w)
570
+ ec_w_in = getattr(ec_ax, '_fixed_ec_w_in', ec_wf * fig_w)
571
+ else:
572
+ ec_gap_in = 0.0
573
+ ec_w_in = 0.0
574
+
575
+ # Operando image state
576
+ import numpy as _np
577
+ arr = im.get_array()
578
+ # Use masked arrays to preserve NaNs if present
579
+ data = _np.array(arr) # preserves mask where possible
580
+ extent = tuple(map(float, im.get_extent())) if hasattr(im, 'get_extent') else None
581
+ # Get colormap name: first check if we stored it explicitly, otherwise try to get from colormap object
582
+ cmap_name = getattr(im, '_operando_cmap_name', None)
583
+ if cmap_name is None:
584
+ cmap_name = getattr(im.get_cmap(), 'name', None)
585
+ clim = tuple(map(float, im.get_clim())) if hasattr(im, 'get_clim') else None
586
+ origin = getattr(im, 'origin', 'upper')
587
+ interpolation = getattr(im, 'get_interpolation', lambda: None)() or 'nearest'
588
+
589
+ # Labels and limits for operando
590
+ # Capture label text and padding (labelpad)
591
+ try:
592
+ _xlp = float(getattr(ax.xaxis, 'labelpad', 0.0))
593
+ except Exception:
594
+ _xlp = 0.0
595
+ try:
596
+ _ylp = float(getattr(ax.yaxis, 'labelpad', 0.0))
597
+ except Exception:
598
+ _ylp = 0.0
599
+ op_labels = {
600
+ 'xlabel': ax.get_xlabel(),
601
+ 'ylabel': ax.get_ylabel(),
602
+ 'xlim': tuple(map(float, ax.get_xlim())),
603
+ 'ylim': tuple(map(float, ax.get_ylim())),
604
+ 'x_labelpad': _xlp,
605
+ 'y_labelpad': _ylp,
606
+ }
607
+ op_custom = getattr(ax, '_custom_labels', {'x': None, 'y': None})
608
+
609
+ # Colorbar label (Colorbar lacks get_label in some versions; use its axes ylabel)
610
+ try:
611
+ cb_label = cbar.ax.get_ylabel()
612
+ except Exception:
613
+ cb_label = ''
614
+ # Capture color scale limits (clim) through the mappable
615
+ try:
616
+ cb_clim = tuple(map(float, im.get_clim()))
617
+ except Exception:
618
+ cb_clim = None
619
+
620
+ # Helper to capture WASD state for an axis
621
+ def _capture_wasd_state(axis):
622
+ ts = getattr(axis, '_saved_tick_state', {})
623
+ wasd = {}
624
+ for side in ('top', 'bottom', 'left', 'right'):
625
+ sp = axis.spines.get(side)
626
+ prefix = {'top': 't', 'bottom': 'b', 'left': 'l', 'right': 'r'}[side]
627
+ # For 'left' side ylabel: check if it's currently visible (has text)
628
+ # If hidden but has stored text, the title state should be False (hidden)
629
+ if side == 'left':
630
+ ylabel_text = axis.get_ylabel()
631
+ title_state = bool(ylabel_text) # True only if currently visible with text
632
+ elif side == 'bottom':
633
+ title_state = bool(axis.get_xlabel())
634
+ elif side == 'top':
635
+ title_state = bool(getattr(axis, '_top_xlabel_on', False))
636
+ elif side == 'right':
637
+ title_state = bool(getattr(axis, '_right_ylabel_on', False))
638
+ else:
639
+ title_state = False
640
+
641
+ wasd[side] = {
642
+ 'spine': bool(sp.get_visible() if sp else False),
643
+ 'ticks': bool(ts.get(f'{prefix}_ticks', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
644
+ 'minor': bool(ts.get(f'm{prefix}x' if side in ('top','bottom') else f'm{prefix}y', False)),
645
+ 'labels': bool(ts.get(f'{prefix}_labels', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
646
+ 'title': title_state,
647
+ }
648
+ return wasd
649
+
650
+ # Helper to capture spine and tick widths
651
+ def _capture_spine_tick_widths(axis):
652
+ spines = {}
653
+ for name in ('bottom', 'top', 'left', 'right'):
654
+ sp = axis.spines.get(name)
655
+ if sp:
656
+ spines[name] = {
657
+ 'linewidth': float(sp.get_linewidth()),
658
+ 'visible': bool(sp.get_visible()),
659
+ 'color': sp.get_edgecolor()
660
+ }
661
+
662
+ ticks = {
663
+ 'x_major': _current_tick_width(axis.xaxis, 'major'),
664
+ 'x_minor': _current_tick_width(axis.xaxis, 'minor'),
665
+ 'y_major': _current_tick_width(axis.yaxis, 'major'),
666
+ 'y_minor': _current_tick_width(axis.yaxis, 'minor'),
667
+ }
668
+ return spines, ticks
669
+
670
+ # Capture operando WASD state, spines, and tick widths
671
+ op_wasd_state = _capture_wasd_state(ax)
672
+ op_spines, op_ticks = _capture_spine_tick_widths(ax)
673
+
674
+ # Capture operando title offsets
675
+ op_title_offsets = {
676
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
677
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
678
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
679
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
680
+ 'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
681
+ 'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
682
+ }
683
+
684
+ # EC panel (optional)
685
+ ec_state = None
686
+ if ec_ax is not None:
687
+ time_h = _np.asarray(getattr(ec_ax, '_ec_time_h', []), float)
688
+ volt_v = _np.asarray(getattr(ec_ax, '_ec_voltage_v', []), float)
689
+ curr_mA = _np.asarray(getattr(ec_ax, '_ec_current_mA', []), float)
690
+ mode = getattr(ec_ax, '_ec_y_mode', 'time')
691
+ xlim = tuple(map(float, ec_ax.get_xlim()))
692
+ ylim = tuple(map(float, ec_ax.get_ylim()))
693
+ # Persist prior time-mode ylim and any ions array/params
694
+ saved_time_ylim = getattr(ec_ax, '_saved_time_ylim', None)
695
+ ions_abs = _np.asarray(getattr(ec_ax, '_ions_abs', []), float) if getattr(ec_ax, '_ions_abs', None) is not None else None
696
+ ion_params = getattr(ec_ax, '_ion_params', None)
697
+ custom = getattr(ec_ax, '_custom_labels', {'x': None, 'y_time': None, 'y_ions': None})
698
+ # EC line style (if present)
699
+ ln = getattr(ec_ax, '_ec_line', None)
700
+ if ln is None and getattr(ec_ax, 'lines', None):
701
+ try:
702
+ ln = ec_ax.lines[0]
703
+ except Exception:
704
+ ln = None
705
+ line_style = None
706
+ if ln is not None:
707
+ try:
708
+ line_style = {
709
+ 'color': ln.get_color(),
710
+ 'linewidth': float(ln.get_linewidth() or 1.0),
711
+ 'linestyle': ln.get_linestyle() or '-',
712
+ 'alpha': ln.get_alpha(),
713
+ }
714
+ except Exception:
715
+ line_style = None
716
+
717
+ # Capture EC WASD state, spines, and tick widths
718
+ ec_wasd_state = _capture_wasd_state(ec_ax)
719
+ ec_spines, ec_ticks = _capture_spine_tick_widths(ec_ax)
720
+
721
+ # Capture EC title offsets
722
+ ec_title_offsets = {
723
+ 'top_y': float(getattr(ec_ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
724
+ 'top_x': float(getattr(ec_ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
725
+ 'bottom_y': float(getattr(ec_ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
726
+ 'left_x': float(getattr(ec_ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
727
+ 'right_x': float(getattr(ec_ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
728
+ 'right_y': float(getattr(ec_ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
729
+ }
730
+
731
+ ec_state = {
732
+ 'time_h': time_h,
733
+ 'volt_v': volt_v,
734
+ 'curr_mA': curr_mA,
735
+ 'mode': mode,
736
+ 'xlim': xlim,
737
+ 'ylim': ylim,
738
+ 'saved_time_ylim': tuple(map(float, saved_time_ylim)) if isinstance(saved_time_ylim, (list, tuple)) else None,
739
+ 'ions_abs': ions_abs,
740
+ 'ion_params': ion_params,
741
+ 'custom_labels': custom,
742
+ 'line_style': line_style,
743
+ 'wasd_state': ec_wasd_state,
744
+ 'spines': ec_spines,
745
+ 'ticks': {'widths': ec_ticks},
746
+ 'title_offsets': ec_title_offsets,
747
+ 'stored_ylabel': getattr(ec_ax, '_stored_ylabel', None), # Save hidden ylabel text
748
+ 'visible': bool(ec_ax.get_visible()),
749
+ }
750
+
751
+ # Get horizontal offsets if they exist
752
+ cb_h_offset = getattr(cbar.ax, '_cb_h_offset_in', 0.0)
753
+ ec_h_offset = getattr(ec_ax, '_ec_h_offset_in', 0.0) if ec_ax is not None else None
754
+
755
+ sess = {
756
+ 'kind': 'operando_ec',
757
+ 'version': 2,
758
+ 'figure': {'size': (fig_w, fig_h), 'dpi': dpi},
759
+ 'layout_inches': {
760
+ 'cb_w_in': cb_w_in,
761
+ 'cb_gap_in': cb_gap_in,
762
+ 'ax_w_in': ax_w_in,
763
+ 'ax_h_in': ax_h_in,
764
+ 'ec_gap_in': ec_gap_in,
765
+ 'ec_w_in': ec_w_in,
766
+ 'cb_h_offset': float(cb_h_offset),
767
+ 'ec_h_offset': float(ec_h_offset) if ec_h_offset is not None else None,
768
+ },
769
+ 'operando': {
770
+ 'array': data,
771
+ 'extent': extent,
772
+ 'cmap': cmap_name,
773
+ 'clim': clim,
774
+ 'origin': origin,
775
+ 'interpolation': interpolation,
776
+ 'labels': op_labels,
777
+ 'custom_labels': op_custom,
778
+ 'wasd_state': op_wasd_state,
779
+ 'spines': op_spines,
780
+ 'ticks': {'widths': op_ticks},
781
+ 'title_offsets': op_title_offsets,
782
+ 'stored_ylabel': getattr(ax, '_stored_ylabel', None), # Save hidden ylabel text
783
+ },
784
+ 'colorbar': {
785
+ 'label': cb_label,
786
+ 'clim': cb_clim,
787
+ 'visible': bool(cbar.ax.get_visible()),
788
+ 'label_mode': getattr(fig, '_colorbar_label_mode', 'normal'),
789
+ },
790
+ 'ec': ec_state,
791
+ 'font': {
792
+ 'size': plt.rcParams.get('font.size'),
793
+ 'chain': list(plt.rcParams.get('font.sans-serif', [])),
794
+ },
795
+ }
796
+ if skip_confirm:
797
+ target = filename
798
+ else:
799
+ target = _confirm_overwrite(filename)
800
+ if not target:
801
+ print("Session save canceled.")
802
+ return
803
+ # Ensure exact case is preserved (important for macOS case-insensitive filesystem)
804
+ from .utils import ensure_exact_case_filename
805
+ target = ensure_exact_case_filename(target)
806
+
807
+ with open(target, 'wb') as f:
808
+ pickle.dump(sess, f)
809
+ print(f"Operando session saved to {target}")
810
+ except Exception as e: # pragma: no cover - defensive path
811
+ print(f"Error saving operando session: {e}")
812
+
813
+
814
+ def load_operando_session(filename: str):
815
+ """Load an operando+EC session (.pkl) and reconstruct figure and axes.
816
+
817
+ Returns: (fig, ax, im, cbar, ec_ax)
818
+ """
819
+ try:
820
+ with open(filename, 'rb') as f:
821
+ sess = pickle.load(f)
822
+ except Exception as e:
823
+ print(f"Failed to load session: {e}")
824
+ return None
825
+
826
+ if not isinstance(sess, dict) or sess.get('kind') != 'operando_ec':
827
+ print("Not an operando+EC session file.")
828
+ return None
829
+
830
+ # Use standard DPI of 100 instead of saved DPI to avoid display-dependent issues
831
+ # (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
832
+ fig = plt.figure(figsize=tuple(sess['figure']['size']), dpi=100)
833
+ # Disable automatic layout adjustments to preserve saved geometry
834
+ try:
835
+ fig.set_layout_engine('none')
836
+ except Exception:
837
+ try:
838
+ fig.set_tight_layout(False)
839
+ except Exception:
840
+ pass
841
+ W, H = map(float, fig.get_size_inches())
842
+ li = sess['layout_inches']
843
+ cb_wf = max(0.0, float(li['cb_w_in']) / W)
844
+ cb_gap_f = max(0.0, float(li['cb_gap_in']) / W)
845
+ ax_wf = max(0.0, float(li['ax_w_in']) / W)
846
+ ax_hf = max(0.0, float(li['ax_h_in']) / H)
847
+ ec_wf = max(0.0, float(li.get('ec_w_in', 0.0)) / W)
848
+ ec_gap_f = max(0.0, float(li.get('ec_gap_in', 0.0)) / W)
849
+
850
+ total_wf = cb_wf + cb_gap_f + ax_wf + ec_gap_f + ec_wf
851
+ group_left = 0.5 - total_wf / 2.0
852
+ y0 = 0.5 - ax_hf / 2.0
853
+
854
+ # Axes positions
855
+ cb_x0 = group_left
856
+ ax_x0 = cb_x0 + cb_wf + cb_gap_f
857
+ ec_x0 = ax_x0 + ax_wf + ec_gap_f if ec_wf > 0 else None
858
+
859
+ # Create axes
860
+ ax = fig.add_axes([ax_x0, y0, ax_wf, ax_hf])
861
+ cbar_ax = fig.add_axes([cb_x0, y0, cb_wf, ax_hf])
862
+
863
+ # Recreate operando image
864
+ from numpy import ma as _ma
865
+ op = sess['operando']
866
+ arr = _ma.masked_invalid(op['array'])
867
+ extent = tuple(op['extent']) if op['extent'] is not None else None
868
+ cmap_name = op.get('cmap') or 'viridis'
869
+ try:
870
+ if not ensure_colormap(cmap_name):
871
+ cmap_name = 'viridis'
872
+ except Exception:
873
+ cmap_name = 'viridis'
874
+ im = ax.imshow(arr, aspect='auto', origin=op.get('origin', 'upper'), extent=extent,
875
+ cmap=cmap_name, interpolation=op.get('interpolation', 'nearest'))
876
+ # Store the colormap name explicitly so it can be retrieved reliably when saving
877
+ setattr(im, '_operando_cmap_name', cmap_name)
878
+ if op.get('clim'):
879
+ try:
880
+ im.set_clim(*op['clim'])
881
+ except Exception:
882
+ pass
883
+
884
+ # Apply operando WASD state if version 2+ (BEFORE restoring labels!)
885
+ version = sess.get('version', 1)
886
+ if version >= 2:
887
+ op_wasd = op.get('wasd_state')
888
+ if op_wasd and isinstance(op_wasd, dict):
889
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
890
+ try:
891
+ # Apply spines
892
+ for side in ('top', 'bottom', 'left', 'right'):
893
+ if side in op_wasd and 'spine' in op_wasd[side]:
894
+ sp = ax.spines.get(side)
895
+ if sp:
896
+ sp.set_visible(bool(op_wasd[side]['spine']))
897
+ # Apply ticks
898
+ ax.tick_params(axis='x',
899
+ top=bool(op_wasd.get('top', {}).get('ticks', False)),
900
+ bottom=bool(op_wasd.get('bottom', {}).get('ticks', True)),
901
+ labeltop=bool(op_wasd.get('top', {}).get('labels', False)),
902
+ labelbottom=bool(op_wasd.get('bottom', {}).get('labels', True)))
903
+ ax.tick_params(axis='y',
904
+ left=bool(op_wasd.get('left', {}).get('ticks', True)),
905
+ right=bool(op_wasd.get('right', {}).get('ticks', False)),
906
+ labelleft=bool(op_wasd.get('left', {}).get('labels', True)),
907
+ labelright=bool(op_wasd.get('right', {}).get('labels', False)))
908
+ # Apply minor ticks
909
+ if op_wasd.get('top', {}).get('minor') or op_wasd.get('bottom', {}).get('minor'):
910
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
911
+ ax.xaxis.set_minor_formatter(NullFormatter())
912
+ ax.tick_params(axis='x', which='minor',
913
+ top=bool(op_wasd.get('top', {}).get('minor', False)),
914
+ bottom=bool(op_wasd.get('bottom', {}).get('minor', False)))
915
+ if op_wasd.get('left', {}).get('minor') or op_wasd.get('right', {}).get('minor'):
916
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
917
+ ax.yaxis.set_minor_formatter(NullFormatter())
918
+ ax.tick_params(axis='y', which='minor',
919
+ left=bool(op_wasd.get('left', {}).get('minor', False)),
920
+ right=bool(op_wasd.get('right', {}).get('minor', False)))
921
+ # Store WASD state
922
+ op_ts = {}
923
+ for side_key, prefix in [('top', 't'), ('bottom', 'b'), ('left', 'l'), ('right', 'r')]:
924
+ s = op_wasd.get(side_key, {})
925
+ op_ts[f'{prefix}_ticks'] = bool(s.get('ticks', False))
926
+ op_ts[f'{prefix}_labels'] = bool(s.get('labels', False))
927
+ op_ts[f'm{prefix}x' if prefix in 'tb' else f'm{prefix}y'] = bool(s.get('minor', False))
928
+ ax._saved_tick_state = op_ts
929
+ # Apply title flags (must be set before restoring labels below)
930
+ ax._top_xlabel_on = bool(op_wasd.get('top', {}).get('title', False))
931
+ ax._right_ylabel_on = bool(op_wasd.get('right', {}).get('title', False))
932
+ except Exception as e:
933
+ print(f"Warning: Could not apply operando WASD state: {e}")
934
+ else:
935
+ # For version 1 pkl files, assume default visibility
936
+ op_wasd = None
937
+
938
+ # Restore labels and labelpad (respecting WASD title state)
939
+ # Bottom xlabel: restore if title is True (default) or if no WASD state
940
+ bottom_title_on = op_wasd.get('bottom', {}).get('title', True) if op_wasd else True
941
+ if bottom_title_on:
942
+ ax.set_xlabel(op['labels'].get('xlabel') or '')
943
+ try:
944
+ lp = op['labels'].get('x_labelpad')
945
+ if lp is not None:
946
+ ax.set_xlabel(ax.get_xlabel(), labelpad=float(lp))
947
+ except Exception:
948
+ pass
949
+ else:
950
+ ax.set_xlabel('') # Hidden by user via s5
951
+
952
+ # Left ylabel: restore if title is True (default) or if no WASD state
953
+ left_title_on = op_wasd.get('left', {}).get('title', True) if op_wasd else True
954
+ if left_title_on:
955
+ ax.set_ylabel(op['labels'].get('ylabel') or 'Scan index')
956
+ try:
957
+ lp = op['labels'].get('y_labelpad')
958
+ if lp is not None:
959
+ ax.set_ylabel(ax.get_ylabel(), labelpad=float(lp))
960
+ except Exception:
961
+ pass
962
+ else:
963
+ ax.set_ylabel('') # Hidden by user via a5
964
+
965
+ try:
966
+ ax.set_xlim(*op['labels']['xlim'])
967
+ ax.set_ylim(*op['labels']['ylim'])
968
+ except Exception:
969
+ pass
970
+ # Persist custom labels
971
+ setattr(ax, '_custom_labels', dict(op.get('custom_labels', {'x': None, 'y': None})))
972
+
973
+ # Restore stored ylabel if present (for cases where ylabel was hidden with a5)
974
+ stored_ylabel = op.get('stored_ylabel')
975
+ if stored_ylabel is not None:
976
+ setattr(ax, '_stored_ylabel', stored_ylabel)
977
+
978
+ # Restore operando title offsets
979
+ try:
980
+ op_title_offsets = op.get('title_offsets', {})
981
+ if op_title_offsets:
982
+ ax._top_xlabel_manual_offset_y_pts = float(op_title_offsets.get('top_y', 0.0) or 0.0)
983
+ ax._top_xlabel_manual_offset_x_pts = float(op_title_offsets.get('top_x', 0.0) or 0.0)
984
+ ax._bottom_xlabel_manual_offset_y_pts = float(op_title_offsets.get('bottom_y', 0.0) or 0.0)
985
+ ax._left_ylabel_manual_offset_x_pts = float(op_title_offsets.get('left_x', 0.0) or 0.0)
986
+ ax._right_ylabel_manual_offset_x_pts = float(op_title_offsets.get('right_x', 0.0) or 0.0)
987
+ ax._right_ylabel_manual_offset_y_pts = float(op_title_offsets.get('right_y', 0.0) or 0.0)
988
+ except Exception:
989
+ pass
990
+
991
+ # Apply operando spines
992
+ op_spines = op.get('spines', {})
993
+ if op_spines:
994
+ try:
995
+ for name, props in op_spines.items():
996
+ sp = ax.spines.get(name)
997
+ if not sp:
998
+ continue
999
+ if 'linewidth' in props and props['linewidth'] is not None:
1000
+ try:
1001
+ sp.set_linewidth(float(props['linewidth']))
1002
+ except Exception:
1003
+ pass
1004
+ if 'visible' in props and props['visible'] is not None:
1005
+ try:
1006
+ sp.set_visible(bool(props['visible']))
1007
+ except Exception:
1008
+ pass
1009
+ if 'color' in props and props['color'] is not None:
1010
+ try:
1011
+ sp.set_edgecolor(props['color'])
1012
+ if name in ('top', 'bottom'):
1013
+ ax.tick_params(axis='x', which='both', colors=props['color'])
1014
+ ax.xaxis.label.set_color(props['color'])
1015
+ else:
1016
+ ax.tick_params(axis='y', which='both', colors=props['color'])
1017
+ ax.yaxis.label.set_color(props['color'])
1018
+ except Exception:
1019
+ pass
1020
+ except Exception:
1021
+ pass
1022
+
1023
+ # Apply operando tick widths
1024
+ op_tick_widths = op.get('ticks', {}).get('widths', {})
1025
+ if op_tick_widths:
1026
+ try:
1027
+ if op_tick_widths.get('x_major'): ax.tick_params(axis='x', which='major', width=op_tick_widths['x_major'])
1028
+ if op_tick_widths.get('x_minor'): ax.tick_params(axis='x', which='minor', width=op_tick_widths['x_minor'])
1029
+ if op_tick_widths.get('y_major'): ax.tick_params(axis='y', which='major', width=op_tick_widths['y_major'])
1030
+ if op_tick_widths.get('y_minor'): ax.tick_params(axis='y', which='minor', width=op_tick_widths['y_minor'])
1031
+ except Exception:
1032
+ pass
1033
+
1034
+ # Colorbar
1035
+ from matplotlib.colorbar import Colorbar as _Colorbar
1036
+ cbar = _Colorbar(cbar_ax, im)
1037
+ cbar.ax.yaxis.set_ticks_position('left')
1038
+ cbar.ax.yaxis.set_label_position('left')
1039
+ try:
1040
+ cb_meta = sess.get('colorbar', {})
1041
+ label_text = cb_meta.get('label')
1042
+ label_mode = cb_meta.get('label_mode', 'normal')
1043
+ # Set label on the colorbar's axes for better compatibility
1044
+ try:
1045
+ cbar.ax.set_ylabel(label_text or '')
1046
+ except Exception:
1047
+ cbar.set_label(label_text or '')
1048
+ if cb_meta.get('clim'):
1049
+ try:
1050
+ im.set_clim(*cb_meta['clim'])
1051
+ except Exception:
1052
+ pass
1053
+ # Persist custom colorbar attributes for interactive mode
1054
+ setattr(cbar.ax, '_colorbar_label', label_text or (cbar.ax.get_ylabel() or 'Intensity'))
1055
+ setattr(cbar.ax, '_colorbar_label_mode', label_mode)
1056
+ setattr(cbar.ax, '_colorbar_im', im)
1057
+ setattr(fig, '_colorbar_label_mode', label_mode)
1058
+ except Exception:
1059
+ pass
1060
+
1061
+ # Optional EC panel
1062
+ ec_ax = None
1063
+ if ec_wf > 0 and ec_x0 is not None:
1064
+ ec_ax = fig.add_axes([ec_x0, y0, ec_wf, ax_hf])
1065
+ # Basic line
1066
+ ec = sess.get('ec') or {}
1067
+ th = ec.get('time_h')
1068
+ vv = ec.get('volt_v')
1069
+ if th is not None and vv is not None and len(th) == len(vv) and len(th) > 0:
1070
+ # Apply saved style or defaults
1071
+ st = (ec.get('line_style') or {})
1072
+ color = st.get('color', 'tab:blue')
1073
+ lw = float(st.get('linewidth', 1.0) or 1.0)
1074
+ ls = st.get('linestyle', '-') or '-'
1075
+ alpha = st.get('alpha', None)
1076
+ ln, = ec_ax.plot(vv, th, lw=lw, color=color, linestyle=ls, alpha=alpha)
1077
+ setattr(ec_ax, '_ec_line', ln)
1078
+
1079
+ # Stash arrays for interactivity
1080
+ setattr(ec_ax, '_ec_time_h', th)
1081
+ setattr(ec_ax, '_ec_voltage_v', vv)
1082
+ setattr(ec_ax, '_ec_current_mA', ec.get('curr_mA'))
1083
+ # Limits
1084
+ try:
1085
+ if ec.get('xlim'): ec_ax.set_xlim(*ec['xlim'])
1086
+ if ec.get('ylim'): ec_ax.set_ylim(*ec['ylim'])
1087
+ except Exception:
1088
+ pass
1089
+ # Ticks/labels on right
1090
+ try:
1091
+ ec_ax.yaxis.tick_right(); ec_ax.yaxis.set_label_position('right')
1092
+ except Exception:
1093
+ pass
1094
+ # Custom labels storage
1095
+ setattr(ec_ax, '_custom_labels', dict(ec.get('custom_labels', {'x': None, 'y_time': None, 'y_ions': None})))
1096
+ # Persist saved time ylim
1097
+ if isinstance(ec.get('saved_time_ylim'), (list, tuple)):
1098
+ setattr(ec_ax, '_saved_time_ylim', tuple(ec['saved_time_ylim']))
1099
+
1100
+ # Apply EC WASD state BEFORE setting labels (if version 2+)
1101
+ ec_wasd = None
1102
+ if version >= 2:
1103
+ ec_wasd = ec.get('wasd_state')
1104
+ if ec_wasd and isinstance(ec_wasd, dict):
1105
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
1106
+ try:
1107
+ # Apply spines
1108
+ for side in ('top', 'bottom', 'left', 'right'):
1109
+ if side in ec_wasd and 'spine' in ec_wasd[side]:
1110
+ sp = ec_ax.spines.get(side)
1111
+ if sp:
1112
+ sp.set_visible(bool(ec_wasd[side]['spine']))
1113
+ # Apply ticks
1114
+ ec_ax.tick_params(axis='x',
1115
+ top=bool(ec_wasd.get('top', {}).get('ticks', False)),
1116
+ bottom=bool(ec_wasd.get('bottom', {}).get('ticks', True)),
1117
+ labeltop=bool(ec_wasd.get('top', {}).get('labels', False)),
1118
+ labelbottom=bool(ec_wasd.get('bottom', {}).get('labels', True)))
1119
+ ec_ax.tick_params(axis='y',
1120
+ left=bool(ec_wasd.get('left', {}).get('ticks', True)),
1121
+ right=bool(ec_wasd.get('right', {}).get('ticks', False)),
1122
+ labelleft=bool(ec_wasd.get('left', {}).get('labels', True)),
1123
+ labelright=bool(ec_wasd.get('right', {}).get('labels', False)))
1124
+ # Apply minor ticks
1125
+ if ec_wasd.get('top', {}).get('minor') or ec_wasd.get('bottom', {}).get('minor'):
1126
+ ec_ax.xaxis.set_minor_locator(AutoMinorLocator())
1127
+ ec_ax.xaxis.set_minor_formatter(NullFormatter())
1128
+ ec_ax.tick_params(axis='x', which='minor',
1129
+ top=bool(ec_wasd.get('top', {}).get('minor', False)),
1130
+ bottom=bool(ec_wasd.get('bottom', {}).get('minor', False)))
1131
+ if ec_wasd.get('left', {}).get('minor') or ec_wasd.get('right', {}).get('minor'):
1132
+ ec_ax.yaxis.set_minor_locator(AutoMinorLocator())
1133
+ ec_ax.yaxis.set_minor_formatter(NullFormatter())
1134
+ ec_ax.tick_params(axis='y', which='minor',
1135
+ left=bool(ec_wasd.get('left', {}).get('minor', False)),
1136
+ right=bool(ec_wasd.get('right', {}).get('minor', False)))
1137
+ # Store WASD state
1138
+ ec_ts = {}
1139
+ for side_key, prefix in [('top', 't'), ('bottom', 'b'), ('left', 'l'), ('right', 'r')]:
1140
+ s = ec_wasd.get(side_key, {})
1141
+ ec_ts[f'{prefix}_ticks'] = bool(s.get('ticks', False))
1142
+ ec_ts[f'{prefix}_labels'] = bool(s.get('labels', False))
1143
+ ec_ts[f'm{prefix}x' if prefix in 'tb' else f'm{prefix}y'] = bool(s.get('minor', False))
1144
+ ec_ax._saved_tick_state = ec_ts
1145
+ # Apply title flags
1146
+ ec_ax._top_xlabel_on = bool(ec_wasd.get('top', {}).get('title', False))
1147
+ ec_ax._right_ylabel_on = bool(ec_wasd.get('right', {}).get('title', False))
1148
+ except Exception as e:
1149
+ print(f"Warning: Could not apply EC WASD state: {e}")
1150
+
1151
+ # Set xlabel (respecting WASD title state for bottom)
1152
+ bottom_title_on = ec_wasd.get('bottom', {}).get('title', True) if ec_wasd else True
1153
+ if bottom_title_on:
1154
+ ec_ax.set_xlabel((ec.get('custom_labels') or {}).get('x') or 'Voltage (V)')
1155
+ else:
1156
+ ec_ax.set_xlabel('') # Hidden by user via s5
1157
+
1158
+ # Handle ions mode
1159
+ mode = ec.get('mode', 'time')
1160
+ setattr(ec_ax, '_ec_y_mode', mode)
1161
+ if mode == 'ions':
1162
+ try:
1163
+ # Rebuild ions formatter based on stored ions array if present; else leave time labels
1164
+ import numpy as _np
1165
+ t = _np.asarray(th, float)
1166
+ ions_abs = ec.get('ions_abs')
1167
+ ion_params = ec.get('ion_params')
1168
+ if ions_abs is None and ion_params and t is not None:
1169
+ # Fallback: recompute ions from params
1170
+ i_mA = _np.asarray(ec.get('curr_mA'), float)
1171
+ v = _np.asarray(vv, float)
1172
+ dt = _np.diff(t)
1173
+ inc = _np.empty_like(t); inc[0] = 0.0
1174
+ if t.size > 1:
1175
+ inc[1:] = 0.5 * (i_mA[:-1] + i_mA[1:]) * dt
1176
+ cap_mAh = _np.cumsum(inc)
1177
+ mass_g = float(ion_params.get('mass_mg', 0.0)) / 1000.0
1178
+ with _np.errstate(divide='ignore', invalid='ignore'):
1179
+ cap_mAh_g = _np.where(mass_g>0, cap_mAh / mass_g, _np.nan)
1180
+ ions_delta = _np.where(ion_params.get('cap_per_ion_mAh_g', 0.0)>0,
1181
+ cap_mAh_g / float(ion_params['cap_per_ion_mAh_g']), _np.nan)
1182
+ ions_abs = float(ion_params.get('start_ions', 0.0)) + ions_delta
1183
+ if ions_abs is not None and t is not None and len(ions_abs) == len(t):
1184
+ setattr(ec_ax, '_ions_abs', _np.asarray(ions_abs, float))
1185
+ # Install formatter and label
1186
+ from matplotlib.ticker import FuncFormatter, MaxNLocator
1187
+ y0, y1 = ec_ax.get_ylim()
1188
+ ions_y0 = float(_np.interp(y0, t, ions_abs, left=ions_abs[0], right=ions_abs[-1]))
1189
+ ions_y1 = float(_np.interp(y1, t, ions_abs, left=ions_abs[0], right=ions_abs[-1]))
1190
+ rng = abs(ions_y1 - ions_y0)
1191
+ def _nice_step(r, approx=6):
1192
+ if not _np.isfinite(r) or r <= 0:
1193
+ return 1.0
1194
+ raw = r / max(1, approx)
1195
+ exp = _np.floor(_np.log10(raw))
1196
+ base = raw / (10**exp)
1197
+ if base < 1.5: step = 1.0
1198
+ elif base < 3.5: step = 2.0
1199
+ elif base < 7.5: step = 5.0
1200
+ else: step = 10.0
1201
+ return step * (10**exp)
1202
+ step = _nice_step(rng)
1203
+ def _fmt(y, pos):
1204
+ try:
1205
+ val = float(_np.interp(y, t, ions_abs, left=ions_abs[0], right=ions_abs[-1]))
1206
+ if step > 0:
1207
+ val = round(val / step) * step
1208
+ s = ("%f" % val).rstrip('0').rstrip('.')
1209
+ return s
1210
+ except Exception:
1211
+ return ""
1212
+ ec_ax.yaxis.set_major_formatter(FuncFormatter(_fmt))
1213
+ ec_ax.yaxis.set_major_locator(MaxNLocator(nbins='auto', steps=[1,2,5], min_n_ticks=4))
1214
+ # Label (custom if set) - respect WASD right title state
1215
+ right_title_on = ec_wasd.get('right', {}).get('title', True) if ec_wasd else True
1216
+ if right_title_on:
1217
+ lab = (ec_ax._custom_labels.get('y_ions') if getattr(ec_ax, '_custom_labels', {}).get('y_ions') else 'Number of ions')
1218
+ ec_ax.set_ylabel(lab)
1219
+ else:
1220
+ ec_ax.set_ylabel('') # Hidden by user via d5
1221
+ except Exception:
1222
+ pass
1223
+ else:
1224
+ # Time mode label - respect WASD right title state
1225
+ right_title_on = ec_wasd.get('right', {}).get('title', True) if ec_wasd else True
1226
+ if right_title_on:
1227
+ lab = (ec_ax._custom_labels.get('y_time') if getattr(ec_ax, '_custom_labels', {}).get('y_time') else 'Time (h)')
1228
+ try:
1229
+ ec_ax.set_ylabel(lab)
1230
+ except Exception:
1231
+ pass
1232
+ else:
1233
+ ec_ax.set_ylabel('') # Hidden by user via d5
1234
+
1235
+ # Restore stored ylabel if present (for cases where ylabel was hidden)
1236
+ stored_ylabel = ec.get('stored_ylabel')
1237
+ if stored_ylabel is not None:
1238
+ setattr(ec_ax, '_stored_ylabel', stored_ylabel)
1239
+
1240
+ # Restore EC title offsets
1241
+ try:
1242
+ ec_title_offsets = ec.get('title_offsets', {})
1243
+ if ec_title_offsets:
1244
+ ec_ax._top_xlabel_manual_offset_y_pts = float(ec_title_offsets.get('top_y', 0.0) or 0.0)
1245
+ ec_ax._top_xlabel_manual_offset_x_pts = float(ec_title_offsets.get('top_x', 0.0) or 0.0)
1246
+ ec_ax._bottom_xlabel_manual_offset_y_pts = float(ec_title_offsets.get('bottom_y', 0.0) or 0.0)
1247
+ ec_ax._left_ylabel_manual_offset_x_pts = float(ec_title_offsets.get('left_x', 0.0) or 0.0)
1248
+ ec_ax._right_ylabel_manual_offset_x_pts = float(ec_title_offsets.get('right_x', 0.0) or 0.0)
1249
+ ec_ax._right_ylabel_manual_offset_y_pts = float(ec_title_offsets.get('right_y', 0.0) or 0.0)
1250
+ except Exception:
1251
+ pass
1252
+
1253
+ # Apply EC spines (WASD state already applied above)
1254
+ if version >= 2:
1255
+ # Apply EC spines
1256
+ ec_spines = ec.get('spines', {})
1257
+ if ec_spines:
1258
+ try:
1259
+ for name, props in ec_spines.items():
1260
+ sp = ec_ax.spines.get(name)
1261
+ if not sp:
1262
+ continue
1263
+ if 'linewidth' in props and props['linewidth'] is not None:
1264
+ try:
1265
+ sp.set_linewidth(float(props['linewidth']))
1266
+ except Exception:
1267
+ pass
1268
+ if 'visible' in props and props['visible'] is not None:
1269
+ try:
1270
+ sp.set_visible(bool(props['visible']))
1271
+ except Exception:
1272
+ pass
1273
+ if 'color' in props and props['color'] is not None:
1274
+ try:
1275
+ sp.set_edgecolor(props['color'])
1276
+ if name in ('top', 'bottom'):
1277
+ ec_ax.tick_params(axis='x', which='both', colors=props['color'])
1278
+ ec_ax.xaxis.label.set_color(props['color'])
1279
+ else:
1280
+ ec_ax.tick_params(axis='y', which='both', colors=props['color'])
1281
+ ec_ax.yaxis.label.set_color(props['color'])
1282
+ except Exception:
1283
+ pass
1284
+ except Exception:
1285
+ pass
1286
+
1287
+ # Apply EC tick widths
1288
+ ec_tick_widths = ec.get('ticks', {}).get('widths', {})
1289
+ if ec_tick_widths:
1290
+ try:
1291
+ if ec_tick_widths.get('x_major'): ec_ax.tick_params(axis='x', which='major', width=ec_tick_widths['x_major'])
1292
+ if ec_tick_widths.get('x_minor'): ec_ax.tick_params(axis='x', which='minor', width=ec_tick_widths['x_minor'])
1293
+ if ec_tick_widths.get('y_major'): ec_ax.tick_params(axis='y', which='major', width=ec_tick_widths['y_major'])
1294
+ if ec_tick_widths.get('y_minor'): ec_ax.tick_params(axis='y', which='minor', width=ec_tick_widths['y_minor'])
1295
+ except Exception:
1296
+ pass
1297
+
1298
+ # Persist fixed inch parameters from loaded session to attributes
1299
+ # This ensures interactive menu can read correct values
1300
+ try:
1301
+ setattr(cbar_ax, '_fixed_cb_w_in', float(li['cb_w_in']))
1302
+ setattr(cbar_ax, '_fixed_cb_gap_in', float(li['cb_gap_in']))
1303
+ setattr(cbar_ax, '_cb_gap_adjusted', True)
1304
+ setattr(ax, '_fixed_ax_w_in', float(li['ax_w_in']))
1305
+ setattr(ax, '_fixed_ax_h_in', float(li['ax_h_in']))
1306
+ # Restore horizontal offsets
1307
+ cb_h_offset = li.get('cb_h_offset', 0.0)
1308
+ ec_h_offset = li.get('ec_h_offset')
1309
+ setattr(cbar_ax, '_cb_h_offset_in', float(cb_h_offset))
1310
+ if ec_ax is not None:
1311
+ setattr(ec_ax, '_fixed_ec_gap_in', float(li.get('ec_gap_in', 0.0)))
1312
+ setattr(ec_ax, '_fixed_ec_w_in', float(li.get('ec_w_in', 0.0)))
1313
+ # Set flags to prevent auto-adjustment of loaded session geometry
1314
+ setattr(ec_ax, '_ec_gap_adjusted', True)
1315
+ setattr(ec_ax, '_ec_op_width_adjusted', True)
1316
+ if ec_h_offset is not None:
1317
+ setattr(ec_ax, '_ec_h_offset_in', float(ec_h_offset))
1318
+ else:
1319
+ setattr(ec_ax, '_ec_h_offset_in', 0.0)
1320
+ elif ec_h_offset is not None:
1321
+ # EC panel doesn't exist but offset was saved - ignore it
1322
+ pass
1323
+
1324
+ # Apply layout with loaded offsets to ensure visual position matches saved position
1325
+ # This must happen after all offsets and geometry parameters are set
1326
+ try:
1327
+ from .operando_ec_interactive import _apply_group_layout_inches, _ensure_fixed_params
1328
+ # Get current geometry parameters (which should match what was just loaded)
1329
+ cb_w_i, cb_gap_i, ec_gap_i, ec_w_i, ax_w_i, ax_h_i = _ensure_fixed_params(fig, ax, cbar_ax, ec_ax)
1330
+ # Apply layout with loaded offsets (offsets are already set as attributes above)
1331
+ _apply_group_layout_inches(fig, ax, cbar_ax, ec_ax, ax_w_i, ax_h_i, cb_w_i, cb_gap_i, ec_gap_i, ec_w_i)
1332
+ except Exception:
1333
+ # If layout application fails, continue - better to have a slightly wrong layout than crash
1334
+ pass
1335
+ except Exception:
1336
+ pass
1337
+
1338
+ # Apply saved fonts and trigger a refresh redraw
1339
+ try:
1340
+ f = sess.get('font', {})
1341
+ if f.get('chain'):
1342
+ plt.rcParams['font.family'] = 'sans-serif'
1343
+ plt.rcParams['font.sans-serif'] = f['chain']
1344
+ if f.get('size'):
1345
+ plt.rcParams['font.size'] = f['size']
1346
+ except Exception:
1347
+ pass
1348
+
1349
+ # Restore visibility states for colorbar and EC panel
1350
+ try:
1351
+ cb_meta = sess.get('colorbar', {})
1352
+ cb_visible = cb_meta.get('visible', True) # Default to visible if not saved
1353
+ cbar.ax.set_visible(bool(cb_visible))
1354
+ except Exception:
1355
+ pass
1356
+
1357
+ try:
1358
+ if ec_ax is not None:
1359
+ ec = sess.get('ec') or {}
1360
+ ec_visible = ec.get('visible', True) # Default to visible if not saved
1361
+ ec_ax.set_visible(bool(ec_visible))
1362
+ except Exception:
1363
+ pass
1364
+
1365
+ # Return tuple
1366
+ # Rebuild legend based on visible lines
1367
+ try:
1368
+ handles = []
1369
+ labels = []
1370
+ for ln in ax.lines:
1371
+ if ln.get_visible() and not (ln.get_label() or '').startswith('_'):
1372
+ handles.append(ln)
1373
+ labels.append(ln.get_label() or '')
1374
+ if handles:
1375
+ ax.legend(handles, labels)
1376
+ else:
1377
+ leg = ax.get_legend()
1378
+ if leg is not None:
1379
+ try:
1380
+ leg.remove()
1381
+ except Exception:
1382
+ pass
1383
+ except Exception:
1384
+ pass
1385
+ try:
1386
+ fig.canvas.draw()
1387
+ except Exception:
1388
+ try:
1389
+ fig.canvas.draw_idle()
1390
+ except Exception:
1391
+ pass
1392
+ return fig, ax, im, cbar, ec_ax
1393
+
1394
+
1395
+ __all__ = [
1396
+ "dump_session",
1397
+ "dump_operando_session",
1398
+ "load_operando_session",
1399
+ "dump_ec_session",
1400
+ "load_ec_session",
1401
+ "dump_cpc_session",
1402
+ "load_cpc_session",
1403
+ ]
1404
+
1405
+ # --------------------- Electrochem GC session helpers ---------------------------
1406
+
1407
+ def dump_ec_session(
1408
+ filename: str,
1409
+ *,
1410
+ fig,
1411
+ ax,
1412
+ cycle_lines: Dict[int, Dict[str, Any]],
1413
+ skip_confirm: bool = False,
1414
+ ) -> None:
1415
+ """Serialize electrochem GC plot (capacity vs voltage) including data and styles.
1416
+
1417
+ Stores figure size/dpi, axis labels/limits, and for each cycle the charge and
1418
+ discharge line data (x,y) and basic line styles.
1419
+
1420
+ Args:
1421
+ skip_confirm: If True, skip overwrite confirmation (already handled by caller).
1422
+ """
1423
+ try:
1424
+ fig_w, fig_h = map(float, fig.get_size_inches())
1425
+ dpi = int(fig.dpi)
1426
+ # Capture axis state
1427
+ # Label pads
1428
+ try:
1429
+ _xlp = float(getattr(ax.xaxis, 'labelpad', 0.0))
1430
+ except Exception:
1431
+ _xlp = 0.0
1432
+ try:
1433
+ _ylp = float(getattr(ax.yaxis, 'labelpad', 0.0))
1434
+ except Exception:
1435
+ _ylp = 0.0
1436
+ axis = {
1437
+ 'xlabel': _axis_label_text(ax, '_stored_xlabel', ax.get_xlabel),
1438
+ 'ylabel': _axis_label_text(ax, '_stored_ylabel', ax.get_ylabel),
1439
+ 'xlim': tuple(map(float, ax.get_xlim())),
1440
+ 'ylim': tuple(map(float, ax.get_ylim())),
1441
+ 'xscale': getattr(ax, 'get_xscale', lambda: 'linear')(),
1442
+ 'yscale': getattr(ax, 'get_yscale', lambda: 'linear')(),
1443
+ 'x_labelpad': _xlp,
1444
+ 'y_labelpad': _ylp,
1445
+ 'xlabel_visible': bool(ax.xaxis.label.get_visible()),
1446
+ 'ylabel_visible': bool(ax.yaxis.label.get_visible()),
1447
+ 'xlabel_color': ax.xaxis.label.get_color(),
1448
+ 'ylabel_color': ax.yaxis.label.get_color(),
1449
+ }
1450
+ # Helper to capture WASD state
1451
+ def _capture_wasd_state(axis):
1452
+ ts = getattr(axis, '_saved_tick_state', {})
1453
+ wasd = {}
1454
+ for side in ('top', 'bottom', 'left', 'right'):
1455
+ sp = axis.spines.get(side)
1456
+ prefix = {'top': 't', 'bottom': 'b', 'left': 'l', 'right': 'r'}[side]
1457
+ wasd[side] = {
1458
+ 'spine': bool(sp.get_visible() if sp else False),
1459
+ 'ticks': bool(ts.get(f'{prefix}_ticks', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
1460
+ 'minor': bool(ts.get(f'm{prefix}x' if side in ('top','bottom') else f'm{prefix}y', False)),
1461
+ 'labels': bool(ts.get(f'{prefix}_labels', ts.get({'top':'tx','bottom':'bx','left':'ly','right':'ry'}[side], side=='bottom' or side=='left'))),
1462
+ 'title': (
1463
+ bool(getattr(axis, '_top_xlabel_on', False)) if side == 'top'
1464
+ else bool(getattr(axis, '_right_ylabel_on', False)) if side == 'right'
1465
+ else bool(axis.xaxis.label.get_visible()) if side == 'bottom'
1466
+ else bool(axis.yaxis.label.get_visible())
1467
+ ),
1468
+ }
1469
+ return wasd
1470
+
1471
+ # Capture WASD state
1472
+ wasd_state = _capture_wasd_state(ax)
1473
+
1474
+ # Tick visibility state (if present from interactive menu) - kept for backward compatibility
1475
+ tick_state = dict(getattr(ax, '_saved_tick_state', {
1476
+ 'bx': True, 'tx': False, 'ly': True, 'ry': False,
1477
+ 'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
1478
+ }))
1479
+ # Representative tick widths
1480
+ def _tick_width(axis, which: str):
1481
+ return _current_tick_width(axis, which)
1482
+ tick_widths = {
1483
+ 'x_major': _tick_width(ax.xaxis, 'major'),
1484
+ 'x_minor': _tick_width(ax.xaxis, 'minor'),
1485
+ 'y_major': _tick_width(ax.yaxis, 'major'),
1486
+ 'y_minor': _tick_width(ax.yaxis, 'minor'),
1487
+ }
1488
+ # Tick direction
1489
+ tick_direction = getattr(fig, '_tick_direction', 'out')
1490
+ # Spines state
1491
+ spines_state = {
1492
+ name: {
1493
+ 'linewidth': (ax.spines.get(name).get_linewidth() if ax.spines.get(name) else None),
1494
+ 'visible': (ax.spines.get(name).get_visible() if ax.spines.get(name) else None),
1495
+ 'color': (ax.spines.get(name).get_edgecolor() if ax.spines.get(name) else None),
1496
+ } for name in ('bottom','top','left','right')
1497
+ }
1498
+ # Duplicate axis title flags
1499
+ titles = {
1500
+ 'top_x': bool(getattr(ax, '_top_xlabel_on', False)),
1501
+ 'right_y': bool(getattr(ax, '_right_ylabel_on', False)),
1502
+ }
1503
+ # Title offsets
1504
+ title_offsets = {
1505
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1506
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
1507
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
1508
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1509
+ 'right_x': float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
1510
+ 'right_y': float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
1511
+ }
1512
+ # Subplot margins
1513
+ sp = fig.subplotpars
1514
+ subplot_margins = {
1515
+ 'left': float(sp.left),
1516
+ 'right': float(sp.right),
1517
+ 'bottom': float(sp.bottom),
1518
+ 'top': float(sp.top),
1519
+ }
1520
+ # Capture cycles
1521
+ lines_state: Dict[int, Dict[str, Any]] = {}
1522
+ for cyc, parts in cycle_lines.items():
1523
+ entry: Dict[str, Any] = {}
1524
+ # Handle both GC mode (dict with 'charge'/'discharge') and CV mode (direct Line2D)
1525
+ if isinstance(parts, dict):
1526
+ # GC mode: parts is a dict with 'charge' and 'discharge' Line2D objects
1527
+ for role in ("charge", "discharge"):
1528
+ ln = parts.get(role)
1529
+ if ln is None:
1530
+ entry[role] = None
1531
+ continue
1532
+ try:
1533
+ x = np.asarray(ln.get_xdata(), float)
1534
+ y = np.asarray(ln.get_ydata(), float)
1535
+ except Exception:
1536
+ x = np.array([]); y = np.array([])
1537
+ try:
1538
+ # Convert color to hex for consistency with style export
1539
+ color_raw = ln.get_color()
1540
+ try:
1541
+ from matplotlib.colors import to_hex
1542
+ color_hex = to_hex(color_raw)
1543
+ except Exception:
1544
+ # Fallback: try to convert via rgba
1545
+ try:
1546
+ from matplotlib.colors import to_hex, to_rgba
1547
+ color_hex = to_hex(to_rgba(color_raw))
1548
+ except Exception:
1549
+ # Last resort: use as-is if it's already a string
1550
+ color_hex = color_raw if isinstance(color_raw, str) else 'tab:blue'
1551
+ st = {
1552
+ 'color': color_hex,
1553
+ 'linewidth': float(ln.get_linewidth() or 1.0),
1554
+ 'linestyle': ln.get_linestyle() or '-',
1555
+ 'alpha': ln.get_alpha(),
1556
+ 'visible': bool(ln.get_visible()),
1557
+ 'label': ln.get_label() or '',
1558
+ }
1559
+ except Exception:
1560
+ st = {'color': '#1f77b4', 'linewidth': 1.0, 'linestyle': '-', 'alpha': None, 'visible': True, 'label': ''}
1561
+ entry[role] = {'x': x, 'y': y, 'style': st}
1562
+ else:
1563
+ # CV mode: parts is a Line2D object directly
1564
+ ln = parts
1565
+ try:
1566
+ x = np.asarray(ln.get_xdata(), float)
1567
+ y = np.asarray(ln.get_ydata(), float)
1568
+ except Exception:
1569
+ x = np.array([]); y = np.array([])
1570
+ try:
1571
+ # Convert color to hex for consistency with style export
1572
+ color_raw = ln.get_color()
1573
+ try:
1574
+ from matplotlib.colors import to_hex
1575
+ color_hex = to_hex(color_raw)
1576
+ except Exception:
1577
+ # Fallback: try to convert via rgba
1578
+ try:
1579
+ from matplotlib.colors import to_hex, to_rgba
1580
+ color_hex = to_hex(to_rgba(color_raw))
1581
+ except Exception:
1582
+ # Last resort: use as-is if it's already a string
1583
+ color_hex = color_raw if isinstance(color_raw, str) else 'tab:blue'
1584
+ st = {
1585
+ 'color': color_hex,
1586
+ 'linewidth': float(ln.get_linewidth() or 1.0),
1587
+ 'linestyle': ln.get_linestyle() or '-',
1588
+ 'alpha': ln.get_alpha(),
1589
+ 'visible': bool(ln.get_visible()),
1590
+ 'label': ln.get_label() or '',
1591
+ }
1592
+ except Exception:
1593
+ st = {'color': '#1f77b4', 'linewidth': 1.0, 'linestyle': '-', 'alpha': None, 'visible': True, 'label': ''}
1594
+ # Store under 'line' key to distinguish from GC mode's 'charge'/'discharge' keys
1595
+ entry['line'] = {'x': x, 'y': y, 'style': st}
1596
+ lines_state[int(cyc)] = entry
1597
+ legend_visible = False
1598
+ legend_xy_in = None
1599
+ try:
1600
+ leg = ax.get_legend()
1601
+ if leg is not None:
1602
+ legend_visible = bool(leg.get_visible())
1603
+ xy = getattr(fig, '_ec_legend_xy_in', None)
1604
+ if isinstance(xy, (list, tuple)) and len(xy) == 2:
1605
+ legend_xy_in = (float(xy[0]), float(xy[1]))
1606
+ except Exception:
1607
+ legend_xy_in = None
1608
+ sess = {
1609
+ 'kind': 'ec_gc',
1610
+ 'version': 2,
1611
+ 'figure': {'size': (fig_w, fig_h), 'dpi': dpi},
1612
+ 'axis': axis,
1613
+ 'subplot_margins': subplot_margins,
1614
+ 'lines': lines_state,
1615
+ 'font': {
1616
+ 'size': plt.rcParams.get('font.size'),
1617
+ 'chain': list(plt.rcParams.get('font.sans-serif', [])),
1618
+ },
1619
+ 'legend': {
1620
+ 'visible': legend_visible,
1621
+ 'position_inches': legend_xy_in,
1622
+ 'title': getattr(fig, '_ec_legend_title', None),
1623
+ },
1624
+ 'wasd_state': wasd_state,
1625
+ 'tick_state': tick_state,
1626
+ 'tick_widths': tick_widths,
1627
+ 'tick_direction': tick_direction,
1628
+ 'spines': spines_state,
1629
+ 'titles': titles,
1630
+ 'title_offsets': title_offsets,
1631
+ 'mode': getattr(ax, '_is_dqdv_mode', None), # Store dQdV mode flag
1632
+ 'rotation_angle': getattr(fig, '_ec_rotation_angle', 0), # Store rotation angle
1633
+ 'source_paths': list(getattr(fig, '_bp_source_paths', []) or []),
1634
+ 'grid': ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else (
1635
+ any(line.get_visible() for line in ax.get_xgridlines() + ax.get_ygridlines()) if hasattr(ax, 'get_xgridlines') else False
1636
+ ),
1637
+ }
1638
+ if skip_confirm:
1639
+ target = filename
1640
+ else:
1641
+ target = _confirm_overwrite(filename)
1642
+ if not target:
1643
+ print("EC session save canceled.")
1644
+ return
1645
+ with open(target, 'wb') as f:
1646
+ pickle.dump(sess, f)
1647
+ print(f"EC session saved to {target}")
1648
+ except Exception as e:
1649
+ print(f"Error saving EC session: {e}")
1650
+
1651
+
1652
+ def load_ec_session(filename: str):
1653
+ """Load an EC GC session and reconstruct figure, axes, and cycle_lines.
1654
+
1655
+ Returns: (fig, ax, cycle_lines)
1656
+ """
1657
+ try:
1658
+ with open(filename, 'rb') as f:
1659
+ sess = pickle.load(f)
1660
+ except Exception as e:
1661
+ print(f"Failed to load EC session: {e}")
1662
+ return None
1663
+
1664
+ if not isinstance(sess, dict) or sess.get('kind') != 'ec_gc':
1665
+ print("Not an EC GC session file.")
1666
+ return None
1667
+
1668
+ # Use standard DPI of 100 instead of saved DPI to avoid display-dependent issues
1669
+ # (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
1670
+ fig = plt.figure(figsize=tuple(sess['figure']['size']), dpi=100)
1671
+ # Preserve saved geometry by disabling auto layout
1672
+ try:
1673
+ fig.set_layout_engine('none')
1674
+ except Exception:
1675
+ try:
1676
+ fig.set_tight_layout(False)
1677
+ except Exception:
1678
+ pass
1679
+ ax = fig.add_subplot(111)
1680
+ try:
1681
+ fig._bp_source_paths = list(sess.get('source_paths', []) or [])
1682
+ except Exception:
1683
+ fig._bp_source_paths = []
1684
+ try:
1685
+ session_abs = os.path.abspath(filename)
1686
+ sources = list(getattr(fig, '_bp_source_paths', []) or [])
1687
+ if session_abs not in sources:
1688
+ sources.append(session_abs)
1689
+ fig._bp_source_paths = sources
1690
+ except Exception:
1691
+ pass
1692
+
1693
+ def _sanitize_legend_offset(xy):
1694
+ if xy is None or not isinstance(xy, (list, tuple)) or len(xy) != 2:
1695
+ return None
1696
+ try:
1697
+ x_val = float(xy[0])
1698
+ y_val = float(xy[1])
1699
+ except Exception:
1700
+ return None
1701
+ fw, fh = fig.get_size_inches()
1702
+ if fw <= 0 or fh <= 0:
1703
+ return None
1704
+ max_offset = max(fw, fh) * 2.0
1705
+ if abs(x_val) > max_offset or abs(y_val) > max_offset:
1706
+ return None
1707
+ return (x_val, y_val)
1708
+ # Fonts
1709
+ try:
1710
+ f = sess.get('font', {})
1711
+ if f.get('chain'):
1712
+ plt.rcParams['font.family'] = 'sans-serif'
1713
+ plt.rcParams['font.sans-serif'] = f['chain']
1714
+ if f.get('size'):
1715
+ plt.rcParams['font.size'] = f['size']
1716
+ except Exception:
1717
+ pass
1718
+
1719
+ # Apply subplot margins early (prevents label clipping on draw)
1720
+ try:
1721
+ spm = sess.get('subplot_margins', {})
1722
+ if all(k in spm for k in ('left','right','bottom','top')):
1723
+ fig.subplots_adjust(left=float(spm['left']), right=float(spm['right']), bottom=float(spm['bottom']), top=float(spm['top']))
1724
+
1725
+ # Restore exact frame size if stored (for precision)
1726
+ frame_size = sess.get('frame_size')
1727
+ if frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
1728
+ target_w_in, target_h_in = map(float, frame_size)
1729
+ # Get current canvas size
1730
+ canvas_w_in, canvas_h_in = fig.get_size_inches()
1731
+ # Calculate needed fractions to achieve exact frame size
1732
+ if canvas_w_in > 0 and canvas_h_in > 0:
1733
+ # Get current position to preserve centering
1734
+ bbox = ax.get_position()
1735
+ center_x = (bbox.x0 + bbox.x1) / 2.0
1736
+ center_y = (bbox.y0 + bbox.y1) / 2.0
1737
+ # Calculate new fractions
1738
+ new_w_frac = target_w_in / canvas_w_in
1739
+ new_h_frac = target_h_in / canvas_h_in
1740
+ # Reposition to maintain centering
1741
+ new_left = center_x - new_w_frac / 2.0
1742
+ new_right = center_x + new_w_frac / 2.0
1743
+ new_bottom = center_y - new_h_frac / 2.0
1744
+ new_top = center_y + new_h_frac / 2.0
1745
+ # Apply
1746
+ fig.subplots_adjust(left=new_left, right=new_right, bottom=new_bottom, top=new_top)
1747
+ except Exception:
1748
+ pass
1749
+
1750
+ # Rebuild lines
1751
+ raw = sess.get('lines', {})
1752
+ cycle_lines: Dict[int, Dict[str, Any]] = {}
1753
+ # Use a color cycle but keep saved colors primarily
1754
+ for k in sorted(raw.keys(), key=lambda x: int(x)):
1755
+ cyc = int(k)
1756
+ parts = raw[k] or {}
1757
+
1758
+ # Check if this is CV mode (has 'line' key) or GC mode (has 'charge'/'discharge' keys)
1759
+ if 'line' in parts:
1760
+ # CV mode: single line per cycle
1761
+ rec = parts.get('line')
1762
+ ln_obj = None
1763
+ if isinstance(rec, dict) and isinstance(rec.get('x'), np.ndarray) and isinstance(rec.get('y'), np.ndarray):
1764
+ x = np.asarray(rec['x'], float)
1765
+ y = np.asarray(rec['y'], float)
1766
+ st = rec.get('style', {})
1767
+ color = st.get('color', 'tab:blue')
1768
+ lw = float(st.get('linewidth', 1.0))
1769
+ ls = st.get('linestyle', '-') or '-'
1770
+ alpha = st.get('alpha', None)
1771
+ label = st.get('label', f'Cycle {cyc}')
1772
+ try:
1773
+ ln_obj, = ax.plot(x, y, linestyle=ls, linewidth=lw, color=color, alpha=alpha, label=label)
1774
+ vis = bool(st.get('visible', True))
1775
+ ln_obj.set_visible(vis)
1776
+ except Exception:
1777
+ pass
1778
+ cycle_lines[cyc] = ln_obj
1779
+ else:
1780
+ # GC mode: separate charge and discharge lines
1781
+ cyc_entry: Dict[str, Any] = {}
1782
+ for role in ("charge", "discharge"):
1783
+ rec = parts.get(role)
1784
+ ln_obj = None
1785
+ if isinstance(rec, dict) and isinstance(rec.get('x'), np.ndarray) and isinstance(rec.get('y'), np.ndarray):
1786
+ x = np.asarray(rec['x'], float)
1787
+ y = np.asarray(rec['y'], float)
1788
+ st = rec.get('style', {})
1789
+ color = st.get('color', 'tab:blue')
1790
+ lw = float(st.get('linewidth', 1.0))
1791
+ ls = st.get('linestyle', '-') or '-'
1792
+ alpha = st.get('alpha', None)
1793
+ label = st.get('label', f'Cycle {cyc}')
1794
+ if role == 'discharge' and (not label or label.startswith('_')):
1795
+ label = f'Cycle {cyc}' if rec.get('x') is None else '_nolegend_'
1796
+ ln_args = {}
1797
+ try:
1798
+ ln_obj, = ax.plot(x, y, linestyle=ls, linewidth=lw, color=color, alpha=alpha, label=label)
1799
+ vis = bool(st.get('visible', True))
1800
+ ln_obj.set_visible(vis)
1801
+ except Exception:
1802
+ pass
1803
+ cyc_entry[role] = ln_obj
1804
+ cycle_lines[cyc] = cyc_entry
1805
+
1806
+ # Axis labels/limits/scales
1807
+ # Store the labels first, then apply WASD state before actually setting them
1808
+ try:
1809
+ axis = sess.get('axis', {})
1810
+ stored_xlabel = axis.get('xlabel') or ''
1811
+ stored_ylabel = axis.get('ylabel') or ''
1812
+ xlabel_visible = axis.get('xlabel_visible', True)
1813
+ ylabel_visible = axis.get('ylabel_visible', True)
1814
+ xlabel_color = axis.get('xlabel_color')
1815
+ ylabel_color = axis.get('ylabel_color')
1816
+
1817
+ # Scales first
1818
+ try:
1819
+ if axis.get('xscale'): ax.set_xscale(axis.get('xscale'))
1820
+ if axis.get('yscale'): ax.set_yscale(axis.get('yscale'))
1821
+ except Exception:
1822
+ pass
1823
+ if axis.get('xlim'): ax.set_xlim(*axis['xlim'])
1824
+ if axis.get('ylim'): ax.set_ylim(*axis['ylim'])
1825
+ # Label pads saved for later
1826
+ x_labelpad = axis.get('x_labelpad')
1827
+ y_labelpad = axis.get('y_labelpad')
1828
+ except Exception:
1829
+ stored_xlabel = ''
1830
+ stored_ylabel = ''
1831
+ x_labelpad = None
1832
+ y_labelpad = None
1833
+ xlabel_visible = True
1834
+ ylabel_visible = True
1835
+ xlabel_color = None
1836
+ ylabel_color = None
1837
+ if stored_xlabel:
1838
+ try:
1839
+ ax._stored_xlabel = stored_xlabel
1840
+ except Exception:
1841
+ pass
1842
+ if stored_ylabel:
1843
+ try:
1844
+ ax._stored_ylabel = stored_ylabel
1845
+ except Exception:
1846
+ pass
1847
+ try:
1848
+ if xlabel_color:
1849
+ ax._stored_xlabel_color = xlabel_color
1850
+ else:
1851
+ ax._stored_xlabel_color = ax.xaxis.label.get_color()
1852
+ except Exception:
1853
+ pass
1854
+ try:
1855
+ if ylabel_color:
1856
+ ax._stored_ylabel_color = ylabel_color
1857
+ else:
1858
+ ax._stored_ylabel_color = ax.yaxis.label.get_color()
1859
+ except Exception:
1860
+ pass
1861
+ if not hasattr(ax, '_stored_top_xlabel_color'):
1862
+ ax._stored_top_xlabel_color = ax.xaxis.label.get_color()
1863
+ if not hasattr(ax, '_stored_right_ylabel_color'):
1864
+ ax._stored_right_ylabel_color = ax.yaxis.label.get_color()
1865
+
1866
+ # Spines
1867
+ try:
1868
+ sp_meta = sess.get('spines', {})
1869
+ for name, spec in sp_meta.items():
1870
+ sp = ax.spines.get(name)
1871
+ if not sp:
1872
+ continue
1873
+ if spec.get('linewidth') is not None:
1874
+ try:
1875
+ sp.set_linewidth(float(spec['linewidth']))
1876
+ except Exception:
1877
+ pass
1878
+ if spec.get('visible') is not None:
1879
+ try:
1880
+ sp.set_visible(bool(spec['visible']))
1881
+ except Exception:
1882
+ pass
1883
+ if spec.get('color') is not None:
1884
+ try:
1885
+ sp.set_edgecolor(spec['color'])
1886
+ if name in ('top', 'bottom'):
1887
+ ax.tick_params(axis='x', which='both', colors=spec['color'])
1888
+ ax.xaxis.label.set_color(spec['color'])
1889
+ if name == 'top':
1890
+ ax._stored_top_xlabel_color = spec['color']
1891
+ else:
1892
+ ax._stored_xlabel_color = spec['color']
1893
+ else:
1894
+ ax.tick_params(axis='y', which='both', colors=spec['color'])
1895
+ ax.yaxis.label.set_color(spec['color'])
1896
+ if name == 'left':
1897
+ ax._stored_ylabel_color = spec['color']
1898
+ else:
1899
+ ax._stored_right_ylabel_color = spec['color']
1900
+ except Exception:
1901
+ pass
1902
+ except Exception:
1903
+ pass
1904
+
1905
+ # Apply WASD state if version 2+
1906
+ version = sess.get('version', 1)
1907
+ wasd = None
1908
+ if version >= 2:
1909
+ wasd = sess.get('wasd_state')
1910
+ if wasd and isinstance(wasd, dict):
1911
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
1912
+ try:
1913
+ # Apply spines
1914
+ for side in ('top', 'bottom', 'left', 'right'):
1915
+ if side in wasd and 'spine' in wasd[side]:
1916
+ sp = ax.spines.get(side)
1917
+ if sp:
1918
+ sp.set_visible(bool(wasd[side]['spine']))
1919
+ # Apply ticks
1920
+ ax.tick_params(axis='x',
1921
+ top=bool(wasd.get('top', {}).get('ticks', False)),
1922
+ bottom=bool(wasd.get('bottom', {}).get('ticks', True)),
1923
+ labeltop=bool(wasd.get('top', {}).get('labels', False)),
1924
+ labelbottom=bool(wasd.get('bottom', {}).get('labels', True)))
1925
+ ax.tick_params(axis='y',
1926
+ left=bool(wasd.get('left', {}).get('ticks', True)),
1927
+ right=bool(wasd.get('right', {}).get('ticks', False)),
1928
+ labelleft=bool(wasd.get('left', {}).get('labels', True)),
1929
+ labelright=bool(wasd.get('right', {}).get('labels', False)))
1930
+ # Apply minor ticks
1931
+ if wasd.get('top', {}).get('minor') or wasd.get('bottom', {}).get('minor'):
1932
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
1933
+ ax.xaxis.set_minor_formatter(NullFormatter())
1934
+ ax.tick_params(axis='x', which='minor',
1935
+ top=bool(wasd.get('top', {}).get('minor', False)),
1936
+ bottom=bool(wasd.get('bottom', {}).get('minor', False)))
1937
+ if wasd.get('left', {}).get('minor') or wasd.get('right', {}).get('minor'):
1938
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
1939
+ ax.yaxis.set_minor_formatter(NullFormatter())
1940
+ ax.tick_params(axis='y', which='minor',
1941
+ left=bool(wasd.get('left', {}).get('minor', False)),
1942
+ right=bool(wasd.get('right', {}).get('minor', False)))
1943
+ # Store WASD state
1944
+ tick_state = {}
1945
+ for side_key, prefix in [('top', 't'), ('bottom', 'b'), ('left', 'l'), ('right', 'r')]:
1946
+ s = wasd.get(side_key, {})
1947
+ tick_state[f'{prefix}_ticks'] = bool(s.get('ticks', False))
1948
+ tick_state[f'{prefix}_labels'] = bool(s.get('labels', False))
1949
+ tick_state[f'm{prefix}x' if prefix in 'tb' else f'm{prefix}y'] = bool(s.get('minor', False))
1950
+ ax._saved_tick_state = tick_state
1951
+ # Apply title flags
1952
+ ax._top_xlabel_on = bool(wasd.get('top', {}).get('title', False))
1953
+ ax._right_ylabel_on = bool(wasd.get('right', {}).get('title', False))
1954
+ except Exception as e:
1955
+ print(f"Warning: Could not apply WASD state: {e}")
1956
+
1957
+ # Apply tick widths from version 2
1958
+ tw = sess.get('tick_widths', {})
1959
+ if tw:
1960
+ try:
1961
+ if tw.get('x_major') is not None: ax.tick_params(axis='x', which='major', width=float(tw['x_major']))
1962
+ if tw.get('x_minor') is not None: ax.tick_params(axis='x', which='minor', width=float(tw['x_minor']))
1963
+ if tw.get('y_major') is not None: ax.tick_params(axis='y', which='major', width=float(tw['y_major']))
1964
+ if tw.get('y_minor') is not None: ax.tick_params(axis='y', which='minor', width=float(tw['y_minor']))
1965
+ except Exception:
1966
+ pass
1967
+
1968
+ # Apply tick direction from version 2
1969
+ try:
1970
+ tick_direction = sess.get('tick_direction', 'out')
1971
+ if tick_direction:
1972
+ setattr(fig, '_tick_direction', tick_direction)
1973
+ ax.tick_params(axis='both', which='both', direction=tick_direction)
1974
+ except Exception:
1975
+ pass
1976
+
1977
+ # Restore grid state
1978
+ try:
1979
+ grid_enabled = sess.get('grid', False)
1980
+ if grid_enabled:
1981
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
1982
+ else:
1983
+ ax.grid(False)
1984
+ except Exception:
1985
+ pass
1986
+
1987
+ # Apply rotation angle from version 2
1988
+ try:
1989
+ rotation_angle = sess.get('rotation_angle', 0)
1990
+ setattr(fig, '_ec_rotation_angle', rotation_angle)
1991
+ except Exception:
1992
+ pass
1993
+ else:
1994
+ # Version 1 backward compatibility
1995
+ try:
1996
+ tick_state = sess.get('tick_state', {})
1997
+ # Persist on axes for interactive menu init
1998
+ ax._saved_tick_state = dict(tick_state)
1999
+ # Apply visibility
2000
+ ax.tick_params(axis='x',
2001
+ bottom=tick_state.get('bx', True), labelbottom=tick_state.get('bx', True),
2002
+ top=tick_state.get('tx', False), labeltop=tick_state.get('tx', False))
2003
+ ax.tick_params(axis='y',
2004
+ left=tick_state.get('ly', True), labelleft=tick_state.get('ly', True),
2005
+ right=tick_state.get('ry', False), labelright=tick_state.get('ry', False))
2006
+ # Minor ticks
2007
+ if tick_state.get('mbx') or tick_state.get('mtx'):
2008
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
2009
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
2010
+ ax.xaxis.set_minor_formatter(NullFormatter())
2011
+ ax.tick_params(axis='x', which='minor',
2012
+ bottom=tick_state.get('mbx', False),
2013
+ top=tick_state.get('mtx', False),
2014
+ labelbottom=False, labeltop=False)
2015
+ else:
2016
+ ax.tick_params(axis='x', which='minor', bottom=False, top=False, labelbottom=False, labeltop=False)
2017
+ if tick_state.get('mly') or tick_state.get('mry'):
2018
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
2019
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
2020
+ ax.yaxis.set_minor_formatter(NullFormatter())
2021
+ ax.tick_params(axis='y', which='minor',
2022
+ left=tick_state.get('mly', False),
2023
+ right=tick_state.get('mry', False),
2024
+ labelleft=False, labelright=False)
2025
+ else:
2026
+ ax.tick_params(axis='y', which='minor', left=False, right=False, labelleft=False, labelright=False)
2027
+ # Widths
2028
+ tw = sess.get('tick_widths', {})
2029
+ if tw.get('x_major') is not None:
2030
+ ax.tick_params(axis='x', which='major', width=tw['x_major'])
2031
+ if tw.get('x_minor') is not None:
2032
+ ax.tick_params(axis='x', which='minor', width=tw['x_minor'])
2033
+ if tw.get('y_major') is not None:
2034
+ ax.tick_params(axis='y', which='major', width=tw['y_major'])
2035
+ if tw.get('y_minor') is not None:
2036
+ ax.tick_params(axis='y', which='minor', width=tw['y_minor'])
2037
+ except Exception:
2038
+ pass
2039
+
2040
+ # Restore axis labels/visibility after WASD application
2041
+ def _title_pref(side_visible: bool, side_key: str):
2042
+ if wasd and isinstance(wasd, dict):
2043
+ entry = wasd.get(side_key, {})
2044
+ if 'title' in entry:
2045
+ return bool(entry.get('title'))
2046
+ return bool(side_visible)
2047
+ bottom_pref = _title_pref(xlabel_visible, 'bottom')
2048
+ left_pref = _title_pref(ylabel_visible, 'left')
2049
+ if bottom_pref and stored_xlabel:
2050
+ ax.set_xlabel(stored_xlabel)
2051
+ ax.xaxis.label.set_visible(True)
2052
+ color = getattr(ax, '_stored_xlabel_color', None)
2053
+ if color:
2054
+ ax.xaxis.label.set_color(color)
2055
+ else:
2056
+ ax.set_xlabel('')
2057
+ ax.xaxis.label.set_visible(False)
2058
+ if left_pref and stored_ylabel:
2059
+ ax.set_ylabel(stored_ylabel)
2060
+ ax.yaxis.label.set_visible(True)
2061
+ color = getattr(ax, '_stored_ylabel_color', None)
2062
+ if color:
2063
+ ax.yaxis.label.set_color(color)
2064
+ else:
2065
+ ax.set_ylabel('')
2066
+ ax.yaxis.label.set_visible(False)
2067
+
2068
+ # Restore title offsets BEFORE positioning titles
2069
+ try:
2070
+ title_offsets = sess.get('title_offsets', {})
2071
+ if title_offsets:
2072
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
2073
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
2074
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
2075
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
2076
+ ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
2077
+ ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
2078
+ except Exception:
2079
+ pass
2080
+
2081
+ # Duplicate titles
2082
+ try:
2083
+ titles = sess.get('titles', {})
2084
+ if titles.get('top_x'):
2085
+ lbl = ax.get_xlabel() or ''
2086
+ if lbl:
2087
+ txt = getattr(ax, '_top_xlabel_artist', None)
2088
+ if txt is None:
2089
+ txt = ax.text(0.5, 1.02, lbl, ha='center', va='bottom', transform=ax.transAxes)
2090
+ ax._top_xlabel_artist = txt
2091
+ else:
2092
+ txt.set_text(lbl); txt.set_visible(True)
2093
+ ax._top_xlabel_on = True
2094
+ try:
2095
+ color = getattr(ax, '_stored_top_xlabel_color', None)
2096
+ if color and txt is not None:
2097
+ txt.set_color(color)
2098
+ except Exception:
2099
+ pass
2100
+ else:
2101
+ if hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
2102
+ try: ax._top_xlabel_artist.set_visible(False)
2103
+ except Exception: pass
2104
+ ax._top_xlabel_on = False
2105
+ if titles.get('right_y'):
2106
+ lbl = ax.get_ylabel() or ''
2107
+ if lbl:
2108
+ if hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
2109
+ try: ax._right_ylabel_artist.remove()
2110
+ except Exception: pass
2111
+ ax._right_ylabel_artist = ax.text(1.02, 0.5, lbl, rotation=90, va='center', ha='left', transform=ax.transAxes)
2112
+ ax._right_ylabel_on = True
2113
+ try:
2114
+ color = getattr(ax, '_stored_right_ylabel_color', None)
2115
+ if color and ax._right_ylabel_artist is not None:
2116
+ ax._right_ylabel_artist.set_color(color)
2117
+ except Exception:
2118
+ pass
2119
+ else:
2120
+ if hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
2121
+ try: ax._right_ylabel_artist.remove()
2122
+ except Exception: pass
2123
+ ax._right_ylabel_artist = None
2124
+ ax._right_ylabel_on = False
2125
+ except Exception:
2126
+ pass
2127
+
2128
+ # Restore mode flag (e.g., dQdV mode)
2129
+ try:
2130
+ mode = sess.get('mode')
2131
+ if mode is not None:
2132
+ ax._is_dqdv_mode = bool(mode)
2133
+ except Exception:
2134
+ pass
2135
+
2136
+ # Legend visibility/position
2137
+ try:
2138
+ legend_cfg = sess.get('legend', {}) or {}
2139
+ legend_visible = bool(legend_cfg.get('visible', True))
2140
+ legend_xy = _sanitize_legend_offset(legend_cfg.get('position_inches'))
2141
+ legend_title = legend_cfg.get('title')
2142
+ if legend_title:
2143
+ fig._ec_legend_title = legend_title
2144
+ else:
2145
+ fig._ec_legend_title = legend_title or getattr(fig, '_ec_legend_title', None)
2146
+ if not getattr(fig, '_ec_legend_title', None):
2147
+ fig._ec_legend_title = "Cycle"
2148
+ try:
2149
+ fig._ec_legend_user_visible = legend_visible
2150
+ except Exception:
2151
+ pass
2152
+ if legend_xy is not None:
2153
+ fig._ec_legend_xy_in = legend_xy
2154
+ handles = []
2155
+ labels = []
2156
+ for ln in ax.lines:
2157
+ if ln.get_visible():
2158
+ lbl = ln.get_label() or ''
2159
+ if lbl.startswith('_'):
2160
+ continue
2161
+ handles.append(ln)
2162
+ labels.append(lbl)
2163
+ if handles:
2164
+ if legend_xy is not None:
2165
+ fw, fh = fig.get_size_inches()
2166
+ if fw > 0 and fh > 0:
2167
+ fx = 0.5 + legend_xy[0] / fw
2168
+ fy = 0.5 + legend_xy[1] / fh
2169
+ leg = ax.legend(handles, labels, loc='center',
2170
+ bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure,
2171
+ borderaxespad=1.0)
2172
+ else:
2173
+ leg = ax.legend(handles, labels, loc='best', borderaxespad=1.0)
2174
+ else:
2175
+ leg = ax.legend(handles, labels, loc='best', borderaxespad=1.0)
2176
+ if leg is not None:
2177
+ try:
2178
+ leg.set_frame_on(False)
2179
+ except Exception:
2180
+ pass
2181
+ leg.set_visible(legend_visible)
2182
+ try:
2183
+ title_text = getattr(fig, '_ec_legend_title', None) or "Cycle"
2184
+ leg.set_title(title_text)
2185
+ except Exception:
2186
+ pass
2187
+ else:
2188
+ leg = ax.get_legend()
2189
+ if leg is not None:
2190
+ try:
2191
+ leg.remove()
2192
+ except Exception:
2193
+ pass
2194
+ except Exception:
2195
+ pass
2196
+
2197
+ try:
2198
+ fig.canvas.draw()
2199
+ except Exception:
2200
+ try:
2201
+ fig.canvas.draw_idle()
2202
+ except Exception:
2203
+ pass
2204
+ return fig, ax, cycle_lines
2205
+
2206
+ # --------------------- CPC (Capacity-Per-Cycle) session helpers -----------------
2207
+
2208
+ def dump_cpc_session(
2209
+ filename: str,
2210
+ *,
2211
+ fig,
2212
+ ax,
2213
+ ax2,
2214
+ sc_charge,
2215
+ sc_discharge,
2216
+ sc_eff,
2217
+ file_data=None,
2218
+ skip_confirm: bool = False,
2219
+ ):
2220
+ """Serialize CPC plot including scatter data, styles, axes, and legend position.
2221
+
2222
+ Stores arrays for charge/discharge capacities and efficiency vs cycle number,
2223
+ marker styles, axis labels/limits, figure size/dpi, legend position, WASD states,
2224
+ tick widths, spines, frame size, and all visual styling.
2225
+
2226
+ Args:
2227
+ file_data: Optional list of multi-file data dictionaries
2228
+ skip_confirm: If True, skip overwrite confirmation (already handled by caller).
2229
+ """
2230
+ try:
2231
+ import numpy as _np
2232
+ fig_w, fig_h = map(float, fig.get_size_inches())
2233
+ dpi = int(fig.dpi)
2234
+
2235
+ # Extract scatter data
2236
+ def _scatter_xy(sc):
2237
+ try:
2238
+ offs = sc.get_offsets()
2239
+ arr = _np.asarray(offs, float)
2240
+ if arr.ndim == 2 and arr.shape[1] >= 2:
2241
+ return _np.array(arr[:,0], float), _np.array(arr[:,1], float)
2242
+ except Exception:
2243
+ pass
2244
+ return _np.array([]), _np.array([])
2245
+ x_c, y_c = _scatter_xy(sc_charge)
2246
+ x_d, y_d = _scatter_xy(sc_discharge)
2247
+ x_e, y_e = _scatter_xy(sc_eff)
2248
+
2249
+ # Colors and sizes
2250
+ def _color_of(sc):
2251
+ try:
2252
+ from matplotlib.colors import to_hex
2253
+ arr = getattr(sc, 'get_facecolors', lambda: None)()
2254
+ if arr is not None and len(arr):
2255
+ return to_hex(arr[0])
2256
+ c = getattr(sc, 'get_color', lambda: None)()
2257
+ if c is not None:
2258
+ if isinstance(c, (list, tuple)) and c and not isinstance(c, str):
2259
+ return to_hex(c[0])
2260
+ try:
2261
+ return to_hex(c)
2262
+ except Exception:
2263
+ return c
2264
+ except Exception:
2265
+ pass
2266
+ return None
2267
+
2268
+ def _size_of(sc, default=32.0):
2269
+ try:
2270
+ arr = sc.get_sizes()
2271
+ if arr is not None and len(arr):
2272
+ return float(arr[0])
2273
+ except Exception:
2274
+ pass
2275
+ return float(default)
2276
+
2277
+ # Axes frame size (in inches)
2278
+ bbox = ax.get_position()
2279
+ frame_w_in = bbox.width * fig_w
2280
+ frame_h_in = bbox.height * fig_h
2281
+
2282
+ # Save spines state for both ax and ax2
2283
+ spines_state = {}
2284
+ for name, sp in ax.spines.items():
2285
+ spines_state[f'ax_{name}'] = {
2286
+ 'linewidth': sp.get_linewidth(),
2287
+ 'color': sp.get_edgecolor(),
2288
+ 'visible': sp.get_visible(),
2289
+ }
2290
+ for name, sp in ax2.spines.items():
2291
+ spines_state[f'ax2_{name}'] = {
2292
+ 'linewidth': sp.get_linewidth(),
2293
+ 'color': sp.get_edgecolor(),
2294
+ 'visible': sp.get_visible(),
2295
+ }
2296
+
2297
+ # Helper to capture tick widths
2298
+ def _tick_width(axis, which: str):
2299
+ return _current_tick_width(axis, which)
2300
+
2301
+ tick_widths = {
2302
+ 'x_major': _tick_width(ax.xaxis, 'major'),
2303
+ 'x_minor': _tick_width(ax.xaxis, 'minor'),
2304
+ 'ly_major': _tick_width(ax.yaxis, 'major'),
2305
+ 'ly_minor': _tick_width(ax.yaxis, 'minor'),
2306
+ 'ry_major': _tick_width(ax2.yaxis, 'major'),
2307
+ 'ry_minor': _tick_width(ax2.yaxis, 'minor'),
2308
+ }
2309
+
2310
+ # Subplot margins
2311
+ sp = fig.subplotpars
2312
+ subplot_margins = {
2313
+ 'left': float(sp.left),
2314
+ 'right': float(sp.right),
2315
+ 'bottom': float(sp.bottom),
2316
+ 'top': float(sp.top),
2317
+ }
2318
+
2319
+ # Capture WASD state from figure attribute
2320
+ wasd_state = getattr(fig, '_cpc_wasd_state', None)
2321
+ if not isinstance(wasd_state, dict):
2322
+ # Fallback: capture current state
2323
+ wasd_state = {
2324
+ 'top': {
2325
+ 'spine': bool(ax.spines.get('top').get_visible() if ax.spines.get('top') else False),
2326
+ 'ticks': bool(ax.xaxis._major_tick_kw.get('tick2On', False)),
2327
+ 'minor': bool(ax.xaxis._minor_tick_kw.get('tick2On', False)),
2328
+ 'labels': bool(ax.xaxis._major_tick_kw.get('label2On', False)),
2329
+ 'title': bool(getattr(ax, '_top_xlabel_text', None) and getattr(ax, '_top_xlabel_text').get_visible()),
2330
+ },
2331
+ 'bottom': {
2332
+ 'spine': bool(ax.spines.get('bottom').get_visible() if ax.spines.get('bottom') else True),
2333
+ 'ticks': bool(ax.xaxis._major_tick_kw.get('tick1On', True)),
2334
+ 'minor': bool(ax.xaxis._minor_tick_kw.get('tick1On', False)),
2335
+ 'labels': bool(ax.xaxis._major_tick_kw.get('label1On', True)),
2336
+ 'title': bool(ax.get_xlabel()),
2337
+ },
2338
+ 'left': {
2339
+ 'spine': bool(ax.spines.get('left').get_visible() if ax.spines.get('left') else True),
2340
+ 'ticks': bool(ax.yaxis._major_tick_kw.get('tick1On', True)),
2341
+ 'minor': bool(ax.yaxis._minor_tick_kw.get('tick1On', False)),
2342
+ 'labels': bool(ax.yaxis._major_tick_kw.get('label1On', True)),
2343
+ 'title': bool(ax.get_ylabel()),
2344
+ },
2345
+ 'right': {
2346
+ 'spine': bool(ax2.spines.get('right').get_visible() if ax2.spines.get('right') else True),
2347
+ 'ticks': bool(ax2.yaxis._major_tick_kw.get('tick2On', True)),
2348
+ 'minor': bool(ax2.yaxis._minor_tick_kw.get('tick2On', False)),
2349
+ 'labels': bool(ax2.yaxis._major_tick_kw.get('label2On', True)),
2350
+ 'title': bool(ax2.yaxis.get_label().get_text()) and bool(sc_eff.get_visible()),
2351
+ },
2352
+ }
2353
+
2354
+ # Capture stored title texts
2355
+ stored_titles = {
2356
+ 'xlabel': getattr(ax, '_stored_xlabel', ax.get_xlabel()),
2357
+ 'ylabel': getattr(ax, '_stored_ylabel', ax.get_ylabel()),
2358
+ 'top_xlabel': getattr(ax, '_stored_top_xlabel', ''),
2359
+ 'right_ylabel': getattr(ax2, '_stored_ylabel', ax2.get_ylabel()),
2360
+ }
2361
+ # Title offsets
2362
+ title_offsets = {
2363
+ 'top_y': float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
2364
+ 'top_x': float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
2365
+ 'bottom_y': float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
2366
+ 'left_x': float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
2367
+ 'right_x': float(getattr(ax2, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
2368
+ 'right_y': float(getattr(ax2, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
2369
+ }
2370
+
2371
+ meta = {
2372
+ 'kind': 'cpc',
2373
+ 'version': 2, # Incremented version for new format
2374
+ 'figure': {
2375
+ 'size': (fig_w, fig_h),
2376
+ 'dpi': dpi,
2377
+ 'frame_size': (frame_w_in, frame_h_in),
2378
+ 'axes_bbox': {
2379
+ 'left': float(bbox.x0),
2380
+ 'bottom': float(bbox.y0),
2381
+ 'right': float(bbox.x0 + bbox.width),
2382
+ 'top': float(bbox.y0 + bbox.height),
2383
+ },
2384
+ 'subplot_margins': subplot_margins,
2385
+ 'spines': spines_state,
2386
+ },
2387
+ 'axis': {
2388
+ 'xlabel': ax.get_xlabel(),
2389
+ 'ylabel_left': ax.get_ylabel(),
2390
+ 'ylabel_right': ax2.get_ylabel(),
2391
+ 'xlim': tuple(map(float, ax.get_xlim())),
2392
+ 'ylim_left': tuple(map(float, ax.get_ylim())),
2393
+ 'ylim_right': tuple(map(float, ax2.get_ylim())),
2394
+ 'x_labelpad': float(getattr(ax.xaxis, 'labelpad', 0.0) or 0.0),
2395
+ 'y_left_labelpad': float(getattr(ax.yaxis, 'labelpad', 0.0) or 0.0),
2396
+ 'y_right_labelpad': float(getattr(ax2.yaxis, 'labelpad', 0.0) or 0.0),
2397
+ },
2398
+ 'series': {
2399
+ 'charge': {
2400
+ 'x': x_c, 'y': y_c,
2401
+ 'color': _color_of(sc_charge),
2402
+ 'size': _size_of(sc_charge, 32.0),
2403
+ 'alpha': (float(sc_charge.get_alpha()) if sc_charge.get_alpha() is not None else None),
2404
+ 'visible': bool(getattr(sc_charge, 'get_visible', lambda: True)()),
2405
+ 'label': getattr(sc_charge, 'get_label', lambda: 'Charge capacity')() or 'Charge capacity',
2406
+ },
2407
+ 'discharge': {
2408
+ 'x': x_d, 'y': y_d,
2409
+ 'color': _color_of(sc_discharge),
2410
+ 'size': _size_of(sc_discharge, 32.0),
2411
+ 'alpha': (float(sc_discharge.get_alpha()) if sc_discharge.get_alpha() is not None else None),
2412
+ 'visible': bool(getattr(sc_discharge, 'get_visible', lambda: True)()),
2413
+ 'label': getattr(sc_discharge, 'get_label', lambda: 'Discharge capacity')() or 'Discharge capacity',
2414
+ },
2415
+ 'efficiency': {
2416
+ 'x': x_e, 'y': y_e,
2417
+ 'color': _color_of(sc_eff) or '#2ca02c',
2418
+ 'size': _size_of(sc_eff, 40.0),
2419
+ 'alpha': (float(sc_eff.get_alpha()) if sc_eff.get_alpha() is not None else None),
2420
+ 'visible': bool(getattr(sc_eff, 'get_visible', lambda: True)()),
2421
+ 'label': getattr(sc_eff, 'get_label', lambda: 'Coulombic efficiency')() or 'Coulombic efficiency',
2422
+ 'marker': '^',
2423
+ },
2424
+ },
2425
+ 'legend': {
2426
+ 'xy_in': getattr(fig, '_cpc_legend_xy_in', None),
2427
+ 'visible': (bool(ax.get_legend().get_visible()) if ax.get_legend() is not None else False)
2428
+ },
2429
+ 'wasd_state': wasd_state,
2430
+ 'tick_widths': tick_widths,
2431
+ 'stored_titles': stored_titles,
2432
+ 'title_offsets': title_offsets,
2433
+ 'font': {
2434
+ 'size': plt.rcParams.get('font.size'),
2435
+ 'chain': list(plt.rcParams.get('font.sans-serif', [])),
2436
+ },
2437
+ 'grid': ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else (
2438
+ any(line.get_visible() for line in ax.get_xgridlines() + ax.get_ygridlines()) if hasattr(ax, 'get_xgridlines') else False
2439
+ ),
2440
+ }
2441
+
2442
+ # Add multi-file data if available
2443
+ if file_data and isinstance(file_data, list) and len(file_data) > 0:
2444
+ multi_files = []
2445
+ for f in file_data:
2446
+ def _marker_of(sc, default_val):
2447
+ try:
2448
+ m = getattr(sc, 'get_marker', lambda: default_val)()
2449
+ if m is None:
2450
+ return default_val
2451
+ return m
2452
+ except Exception:
2453
+ return default_val
2454
+ def _alpha_of(sc, default_val=None):
2455
+ try:
2456
+ a = sc.get_alpha()
2457
+ return float(a) if a is not None else default_val
2458
+ except Exception:
2459
+ return default_val
2460
+ def _visible_of(sc, default_val=True):
2461
+ try:
2462
+ return bool(sc.get_visible())
2463
+ except Exception:
2464
+ return default_val
2465
+ def _label_of(sc, default_val=""):
2466
+ try:
2467
+ return sc.get_label() or default_val
2468
+ except Exception:
2469
+ return default_val
2470
+ file_info = {
2471
+ 'filename': f.get('filename', 'unknown'),
2472
+ 'visible': f.get('visible', True),
2473
+ 'charge': {
2474
+ 'x': _np.array(_scatter_xy(f.get('sc_charge', sc_charge))[0]),
2475
+ 'y': _np.array(_scatter_xy(f.get('sc_charge', sc_charge))[1]),
2476
+ 'color': _color_of(f.get('sc_charge')),
2477
+ 'size': _size_of(f.get('sc_charge'), 32.0),
2478
+ 'alpha': _alpha_of(f.get('sc_charge')),
2479
+ 'marker': _marker_of(f.get('sc_charge'), 'o'),
2480
+ 'label': _label_of(f.get('sc_charge'), 'Charge capacity'),
2481
+ 'visible': _visible_of(f.get('sc_charge')),
2482
+ },
2483
+ 'discharge': {
2484
+ 'x': _np.array(_scatter_xy(f.get('sc_discharge', sc_discharge))[0]),
2485
+ 'y': _np.array(_scatter_xy(f.get('sc_discharge', sc_discharge))[1]),
2486
+ 'color': _color_of(f.get('sc_discharge')),
2487
+ 'size': _size_of(f.get('sc_discharge'), 32.0),
2488
+ 'alpha': _alpha_of(f.get('sc_discharge')),
2489
+ 'marker': _marker_of(f.get('sc_discharge'), 's'),
2490
+ 'label': _label_of(f.get('sc_discharge'), 'Discharge capacity'),
2491
+ 'visible': _visible_of(f.get('sc_discharge')),
2492
+ },
2493
+ 'efficiency': {
2494
+ 'x': _np.array(_scatter_xy(f.get('sc_eff', sc_eff))[0]),
2495
+ 'y': _np.array(_scatter_xy(f.get('sc_eff', sc_eff))[1]),
2496
+ 'color': _color_of(f.get('sc_eff')),
2497
+ 'size': _size_of(f.get('sc_eff'), 40.0),
2498
+ 'alpha': _alpha_of(f.get('sc_eff')),
2499
+ 'marker': _marker_of(f.get('sc_eff'), '^'),
2500
+ 'label': _label_of(f.get('sc_eff'), 'Coulombic efficiency'),
2501
+ 'visible': _visible_of(f.get('sc_eff')),
2502
+ }
2503
+ }
2504
+ multi_files.append(file_info)
2505
+ meta['multi_files'] = multi_files
2506
+
2507
+ if skip_confirm:
2508
+ target = filename
2509
+ else:
2510
+ target = _confirm_overwrite(filename)
2511
+ if not target:
2512
+ print("CPC session save canceled.")
2513
+ return
2514
+ with open(target, 'wb') as f:
2515
+ pickle.dump(meta, f)
2516
+ print(f"CPC session saved to {target}")
2517
+ except Exception as e:
2518
+ print(f"Error saving CPC session: {e}")
2519
+
2520
+
2521
+ def load_cpc_session(filename: str):
2522
+ """Load a CPC session and reconstruct fig, axes, scatter artists, and file_data.
2523
+
2524
+ Returns: (fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data)
2525
+ """
2526
+ try:
2527
+ with open(filename, 'rb') as f:
2528
+ sess = pickle.load(f)
2529
+ except Exception as e:
2530
+ print(f"Failed to load session: {e}")
2531
+ return None
2532
+ if not isinstance(sess, dict) or sess.get('kind') != 'cpc':
2533
+ print("Not a CPC session file.")
2534
+ return None
2535
+ try:
2536
+ # Use standard DPI of 100 instead of saved DPI to avoid display-dependent issues
2537
+ # (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
2538
+ fig = plt.figure(figsize=tuple(sess['figure']['size']), dpi=100)
2539
+ # Disable auto layout
2540
+ try:
2541
+ fig.set_layout_engine('none')
2542
+ except Exception:
2543
+ try:
2544
+ fig.set_tight_layout(False)
2545
+ except Exception:
2546
+ pass
2547
+ ax = fig.add_subplot(111)
2548
+ ax2 = ax.twinx()
2549
+ # Fonts
2550
+ try:
2551
+ f = sess.get('font', {})
2552
+ if f.get('chain'):
2553
+ plt.rcParams['font.family'] = 'sans-serif'
2554
+ plt.rcParams['font.sans-serif'] = f['chain']
2555
+ if f.get('size'):
2556
+ plt.rcParams['font.size'] = f['size']
2557
+ except Exception:
2558
+ pass
2559
+ # Labels and limits
2560
+ ax_meta = sess.get('axis', {})
2561
+ try:
2562
+ ax.set_xlabel(ax_meta.get('xlabel') or 'Cycle number')
2563
+ ax.set_ylabel(ax_meta.get('ylabel_left') or r'Specific Capacity (mAh g$^{-1}$)')
2564
+ ax2.set_ylabel(ax_meta.get('ylabel_right') or 'Efficiency (%)')
2565
+ if ax_meta.get('xlim'): ax.set_xlim(*ax_meta['xlim'])
2566
+ if ax_meta.get('ylim_left'): ax.set_ylim(*ax_meta['ylim_left'])
2567
+ if ax_meta.get('ylim_right'): ax2.set_ylim(*ax_meta['ylim_right'])
2568
+ # Label pads
2569
+ try:
2570
+ lp = ax_meta.get('x_labelpad')
2571
+ if lp is not None:
2572
+ ax.set_xlabel(ax_meta.get('xlabel') or 'Cycle number', labelpad=float(lp))
2573
+ except Exception:
2574
+ pass
2575
+ try:
2576
+ lp = ax_meta.get('y_left_labelpad')
2577
+ if lp is not None:
2578
+ ax.set_ylabel(ax_meta.get('ylabel_left') or r'Specific Capacity (mAh g$^{-1}$)', labelpad=float(lp))
2579
+ except Exception:
2580
+ pass
2581
+ try:
2582
+ lp = ax_meta.get('y_right_labelpad')
2583
+ if lp is not None:
2584
+ ax2.set_ylabel(ax_meta.get('ylabel_right') or 'Efficiency (%)', labelpad=float(lp))
2585
+ except Exception:
2586
+ pass
2587
+ except Exception:
2588
+ pass
2589
+ # Series
2590
+ sr = sess.get('series', {})
2591
+ ch = sr.get('charge', {})
2592
+ dh = sr.get('discharge', {})
2593
+ ef = sr.get('efficiency', {})
2594
+ def _mk_sc(axX, rec, default_marker='o'):
2595
+ import numpy as _np
2596
+ x_val = rec.get('x')
2597
+ x = _np.asarray(x_val if x_val is not None else [], float)
2598
+ y_val = rec.get('y')
2599
+ y = _np.asarray(y_val if y_val is not None else [], float)
2600
+ col = rec.get('color') or 'tab:blue'
2601
+ s = float(rec.get('size', 32.0) or 32.0)
2602
+ alpha = rec.get('alpha', None)
2603
+ marker = rec.get('marker', default_marker)
2604
+ lab = rec.get('label') or ''
2605
+ sc = axX.scatter(x, y, color=col, s=s, alpha=alpha, marker=marker, label=lab, zorder=3)
2606
+ try:
2607
+ sc.set_visible(bool(rec.get('visible', True)))
2608
+ except Exception:
2609
+ pass
2610
+ return sc
2611
+ # If multi_files exist, rebuild all files and pick the first as primary
2612
+ multi_files = sess.get('multi_files')
2613
+ file_data = []
2614
+ if multi_files and isinstance(multi_files, list) and len(multi_files) > 0:
2615
+ for idx, finfo in enumerate(multi_files):
2616
+ ch_info = finfo.get('charge', {})
2617
+ dh_info = finfo.get('discharge', {})
2618
+ ef_info = finfo.get('efficiency', {})
2619
+ sc_ch = _mk_sc(ax, ch_info, ch_info.get('marker', 'o') or 'o')
2620
+ sc_dh = _mk_sc(ax, dh_info, dh_info.get('marker', 's') or 's')
2621
+ eff_marker = ef_info.get('marker', '^') or '^'
2622
+ sc_ef = _mk_sc(ax2, ef_info, eff_marker)
2623
+ # Respect overall file visibility
2624
+ try:
2625
+ vis_file = bool(finfo.get('visible', True))
2626
+ except Exception:
2627
+ vis_file = True
2628
+ for sc_tmp in (sc_ch, sc_dh, sc_ef):
2629
+ try:
2630
+ sc_tmp.set_visible(sc_tmp.get_visible() and vis_file)
2631
+ except Exception:
2632
+ pass
2633
+ file_data.append({
2634
+ 'filename': finfo.get('filename', f'File {idx+1}'),
2635
+ 'visible': vis_file,
2636
+ 'sc_charge': sc_ch,
2637
+ 'sc_discharge': sc_dh,
2638
+ 'sc_eff': sc_ef,
2639
+ })
2640
+ # Use the first file as primary artists for interactive menu
2641
+ sc_charge = file_data[0]['sc_charge']
2642
+ sc_discharge = file_data[0]['sc_discharge']
2643
+ sc_eff = file_data[0]['sc_eff']
2644
+ else:
2645
+ # No multi-file info: fall back to single-file series
2646
+ sc_charge = _mk_sc(ax, ch, 'o')
2647
+ sc_discharge = _mk_sc(ax, dh, 's')
2648
+ if 'marker' not in ef:
2649
+ ef['marker'] = '^'
2650
+ sc_eff = _mk_sc(ax2, ef, '^')
2651
+ file_data = None
2652
+
2653
+ # Restore spines state (version 2+)
2654
+ try:
2655
+ if not hasattr(fig, '_cpc_spine_colors') or not isinstance(fig._cpc_spine_colors, dict):
2656
+ fig._cpc_spine_colors = {}
2657
+ fig_meta = sess.get('figure', {})
2658
+ spines_state = fig_meta.get('spines', {})
2659
+ for key, props in spines_state.items():
2660
+ if key.startswith('ax_'):
2661
+ name = key[3:] # Remove 'ax_' prefix
2662
+ if name in ax.spines:
2663
+ sp = ax.spines[name]
2664
+ if 'linewidth' in props:
2665
+ sp.set_linewidth(props['linewidth'])
2666
+ if 'color' in props:
2667
+ sp.set_edgecolor(props['color'])
2668
+ if name in ('top','bottom'):
2669
+ ax.tick_params(axis='x', which='both', colors=props['color'])
2670
+ ax.xaxis.label.set_color(props['color'])
2671
+ else:
2672
+ ax.tick_params(axis='y', which='both', colors=props['color'])
2673
+ ax.yaxis.label.set_color(props['color'])
2674
+ fig._cpc_spine_colors[name] = props['color']
2675
+ if 'visible' in props:
2676
+ sp.set_visible(props['visible'])
2677
+ elif key.startswith('ax2_'):
2678
+ name = key[4:] # Remove 'ax2_' prefix
2679
+ if name in ax2.spines:
2680
+ sp = ax2.spines[name]
2681
+ if 'linewidth' in props:
2682
+ sp.set_linewidth(props['linewidth'])
2683
+ if 'color' in props:
2684
+ sp.set_edgecolor(props['color'])
2685
+ ax2.tick_params(axis='y', which='both', colors=props['color'])
2686
+ ax2.yaxis.label.set_color(props['color'])
2687
+ fig._cpc_spine_colors['right' if name == 'right' else name] = props['color']
2688
+ if 'visible' in props:
2689
+ sp.set_visible(props['visible'])
2690
+ except Exception:
2691
+ pass
2692
+
2693
+ # Restore tick widths (version 2+)
2694
+ try:
2695
+ tick_widths = sess.get('tick_widths', {})
2696
+ if tick_widths.get('x_major') is not None:
2697
+ ax.tick_params(axis='x', which='major', width=tick_widths['x_major'])
2698
+ if tick_widths.get('x_minor') is not None:
2699
+ ax.tick_params(axis='x', which='minor', width=tick_widths['x_minor'])
2700
+ if tick_widths.get('ly_major') is not None:
2701
+ ax.tick_params(axis='y', which='major', width=tick_widths['ly_major'])
2702
+ if tick_widths.get('ly_minor') is not None:
2703
+ ax.tick_params(axis='y', which='minor', width=tick_widths['ly_minor'])
2704
+ if tick_widths.get('ry_major') is not None:
2705
+ ax2.tick_params(axis='y', which='major', width=tick_widths['ry_major'])
2706
+ if tick_widths.get('ry_minor') is not None:
2707
+ ax2.tick_params(axis='y', which='minor', width=tick_widths['ry_minor'])
2708
+ except Exception:
2709
+ pass
2710
+
2711
+ # Restore tick direction (version 2+)
2712
+ try:
2713
+ tick_direction = sess.get('tick_direction', 'out')
2714
+ if tick_direction:
2715
+ setattr(fig, '_tick_direction', tick_direction)
2716
+ ax.tick_params(axis='both', which='both', direction=tick_direction)
2717
+ ax2.tick_params(axis='both', which='both', direction=tick_direction)
2718
+ except Exception:
2719
+ pass
2720
+
2721
+ # Restore grid state
2722
+ try:
2723
+ grid_enabled = sess.get('grid', False)
2724
+ if grid_enabled:
2725
+ ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
2726
+ else:
2727
+ ax.grid(False)
2728
+ except Exception:
2729
+ pass
2730
+
2731
+ # Restore subplot margins/frame size (version 2+)
2732
+ try:
2733
+ fig_meta = sess.get('figure', {})
2734
+ margins = fig_meta.get('subplot_margins', {})
2735
+ if margins is not None and isinstance(margins, dict):
2736
+ fig.subplots_adjust(
2737
+ left=margins.get('left', 0.125),
2738
+ right=margins.get('right', 0.9),
2739
+ bottom=margins.get('bottom', 0.11),
2740
+ top=margins.get('top', 0.88)
2741
+ )
2742
+
2743
+ # Restore exact frame size if stored (for precision)
2744
+ frame_size = fig_meta.get('frame_size')
2745
+ if frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
2746
+ target_w_in, target_h_in = map(float, frame_size)
2747
+ # Get current canvas size
2748
+ canvas_w_in, canvas_h_in = fig.get_size_inches()
2749
+ # Calculate needed fractions to achieve exact frame size
2750
+ if canvas_w_in > 0 and canvas_h_in > 0:
2751
+ # Get current position to preserve centering
2752
+ bbox = ax.get_position()
2753
+ center_x = (bbox.x0 + bbox.x1) / 2.0
2754
+ center_y = (bbox.y0 + bbox.y1) / 2.0
2755
+ # Calculate new fractions
2756
+ new_w_frac = target_w_in / canvas_w_in
2757
+ new_h_frac = target_h_in / canvas_h_in
2758
+ # Reposition to maintain centering
2759
+ new_left = center_x - new_w_frac / 2.0
2760
+ new_right = center_x + new_w_frac / 2.0
2761
+ new_bottom = center_y - new_h_frac / 2.0
2762
+ new_top = center_y + new_h_frac / 2.0
2763
+ # Apply
2764
+ fig.subplots_adjust(left=new_left, right=new_right, bottom=new_bottom, top=new_top)
2765
+ except Exception:
2766
+ pass
2767
+
2768
+ # Restore WASD state (version 2+)
2769
+ try:
2770
+ wasd_state = sess.get('wasd_state', {})
2771
+ if wasd_state is not None and isinstance(wasd_state, dict) and wasd_state:
2772
+ # Store on figure for interactive menu
2773
+ fig._cpc_wasd_state = wasd_state
2774
+
2775
+ # Apply WASD state
2776
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
2777
+
2778
+ # Spines
2779
+ if 'top' in wasd_state:
2780
+ ax.spines['top'].set_visible(wasd_state['top'].get('spine', False))
2781
+ ax2.spines['top'].set_visible(wasd_state['top'].get('spine', False))
2782
+ if 'bottom' in wasd_state:
2783
+ ax.spines['bottom'].set_visible(wasd_state['bottom'].get('spine', True))
2784
+ ax2.spines['bottom'].set_visible(wasd_state['bottom'].get('spine', True))
2785
+ if 'left' in wasd_state:
2786
+ ax.spines['left'].set_visible(wasd_state['left'].get('spine', True))
2787
+ if 'right' in wasd_state:
2788
+ ax2.spines['right'].set_visible(wasd_state['right'].get('spine', True))
2789
+
2790
+ # Tick visibility
2791
+ if 'top' in wasd_state and 'bottom' in wasd_state:
2792
+ ax.tick_params(axis='x',
2793
+ top=wasd_state['top'].get('ticks', False),
2794
+ bottom=wasd_state['bottom'].get('ticks', True),
2795
+ labeltop=wasd_state['top'].get('labels', False),
2796
+ labelbottom=wasd_state['bottom'].get('labels', True))
2797
+ if 'left' in wasd_state:
2798
+ ax.tick_params(axis='y',
2799
+ left=wasd_state['left'].get('ticks', True),
2800
+ labelleft=wasd_state['left'].get('labels', True))
2801
+ if 'right' in wasd_state:
2802
+ ax2.tick_params(axis='y',
2803
+ right=wasd_state['right'].get('ticks', True),
2804
+ labelright=wasd_state['right'].get('labels', True))
2805
+ # Axis title visibility
2806
+ try:
2807
+ if 'bottom' in wasd_state:
2808
+ ax.xaxis.label.set_visible(bool(wasd_state['bottom'].get('title', True)))
2809
+ if 'left' in wasd_state:
2810
+ ax.yaxis.label.set_visible(bool(wasd_state['left'].get('title', True)))
2811
+ if 'right' in wasd_state:
2812
+ ax2.yaxis.label.set_visible(bool(wasd_state['right'].get('title', True)))
2813
+ except Exception:
2814
+ pass
2815
+
2816
+ # Minor ticks
2817
+ if wasd_state.get('top', {}).get('minor') or wasd_state.get('bottom', {}).get('minor'):
2818
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
2819
+ ax.xaxis.set_minor_formatter(NullFormatter())
2820
+ ax.tick_params(axis='x', which='minor',
2821
+ top=wasd_state.get('top', {}).get('minor', False),
2822
+ bottom=wasd_state.get('bottom', {}).get('minor', False))
2823
+ if wasd_state.get('left', {}).get('minor'):
2824
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
2825
+ ax.yaxis.set_minor_formatter(NullFormatter())
2826
+ ax.tick_params(axis='y', which='minor', left=True)
2827
+ if wasd_state.get('right', {}).get('minor'):
2828
+ ax2.yaxis.set_minor_locator(AutoMinorLocator())
2829
+ ax2.yaxis.set_minor_formatter(NullFormatter())
2830
+ ax2.tick_params(axis='y', which='minor', right=True)
2831
+ except Exception:
2832
+ pass
2833
+
2834
+ # Restore tick widths (version 2+)
2835
+ try:
2836
+ tw = sess.get('tick_widths', {})
2837
+ if tw:
2838
+ if tw.get('x_major') is not None:
2839
+ ax.tick_params(axis='x', which='major', width=float(tw['x_major']))
2840
+ if tw.get('x_minor') is not None:
2841
+ ax.tick_params(axis='x', which='minor', width=float(tw['x_minor']))
2842
+ if tw.get('ly_major') is not None:
2843
+ ax.tick_params(axis='y', which='major', width=float(tw['ly_major']))
2844
+ if tw.get('ly_minor') is not None:
2845
+ ax.tick_params(axis='y', which='minor', width=float(tw['ly_minor']))
2846
+ if tw.get('ry_major') is not None:
2847
+ ax2.tick_params(axis='y', which='major', width=float(tw['ry_major']))
2848
+ if tw.get('ry_minor') is not None:
2849
+ ax2.tick_params(axis='y', which='minor', width=float(tw['ry_minor']))
2850
+ except Exception:
2851
+ pass
2852
+
2853
+ # Restore title offsets BEFORE restoring titles
2854
+ try:
2855
+ title_offsets = sess.get('title_offsets', {})
2856
+ if title_offsets:
2857
+ ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
2858
+ ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
2859
+ ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
2860
+ ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
2861
+ ax2._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
2862
+ ax2._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
2863
+ except Exception:
2864
+ pass
2865
+
2866
+ # Restore stored title texts (version 2+)
2867
+ try:
2868
+ stored_titles = sess.get('stored_titles', {})
2869
+ if stored_titles is not None and isinstance(stored_titles, dict) and stored_titles:
2870
+ ax._stored_xlabel = stored_titles.get('xlabel', '')
2871
+ ax._stored_ylabel = stored_titles.get('ylabel', '')
2872
+ ax._stored_top_xlabel = stored_titles.get('top_xlabel', '')
2873
+ ax2._stored_ylabel = stored_titles.get('right_ylabel', '')
2874
+
2875
+ # Create top xlabel text if it was visible
2876
+ if wasd_state.get('top', {}).get('title') and ax._stored_top_xlabel:
2877
+ ax._top_xlabel_text = ax.text(0.5, 1.02, ax._stored_top_xlabel,
2878
+ transform=ax.transAxes,
2879
+ ha='center', va='bottom',
2880
+ fontsize=ax.xaxis.label.get_fontsize(),
2881
+ fontfamily=ax.xaxis.label.get_fontfamily())
2882
+ except Exception:
2883
+ pass
2884
+
2885
+ # Legend
2886
+ try:
2887
+ handles1, labels1 = ax.get_legend_handles_labels()
2888
+ handles2, labels2 = ax2.get_legend_handles_labels()
2889
+ # Filter visible handles only
2890
+ H, L = [], []
2891
+ for h, l in list(zip(handles1, labels1)) + list(zip(handles2, labels2)):
2892
+ try:
2893
+ if hasattr(h, 'get_visible') and not h.get_visible():
2894
+ continue
2895
+ except Exception:
2896
+ pass
2897
+ H.append(h); L.append(l)
2898
+ leg_meta = sess.get('legend', {})
2899
+ xy_in = leg_meta.get('xy_in')
2900
+ vis = bool(leg_meta.get('visible', True))
2901
+ if H and vis:
2902
+ if xy_in is not None:
2903
+ fw, fh = fig.get_size_inches()
2904
+ fx = 0.5 + float(xy_in[0]) / float(fw)
2905
+ fy = 0.5 + float(xy_in[1]) / float(fh)
2906
+ # Use same spacing parameters as _legend_no_frame for consistent legend appearance
2907
+ leg = ax.legend(H, L, loc='center', bbox_to_anchor=(fx, fy), bbox_transform=fig.transFigure,
2908
+ handlelength=1.0, handletextpad=0.35, labelspacing=0.25,
2909
+ borderaxespad=0.5, borderpad=0.3, columnspacing=0.6,
2910
+ labelcolor='linecolor', frameon=False)
2911
+ # persist inches on fig for interactive menu
2912
+ try:
2913
+ fig._cpc_legend_xy_in = (float(xy_in[0]), float(xy_in[1]))
2914
+ except Exception:
2915
+ pass
2916
+ else:
2917
+ # Use same spacing parameters as _legend_no_frame for consistent legend appearance
2918
+ leg = ax.legend(H, L, loc='best',
2919
+ handlelength=1.0, handletextpad=0.35, labelspacing=0.25,
2920
+ borderaxespad=0.5, borderpad=0.3, columnspacing=0.6,
2921
+ labelcolor='linecolor', frameon=False)
2922
+ # Ensure legend frame is off (redundant but safe)
2923
+ try:
2924
+ if leg is not None:
2925
+ leg.set_frame_on(False)
2926
+ except Exception:
2927
+ pass
2928
+ else:
2929
+ try:
2930
+ fig._cpc_legend_xy_in = (float(xy_in[0]), float(xy_in[1])) if xy_in is not None else None
2931
+ except Exception:
2932
+ pass
2933
+ # ensure legend hidden
2934
+ leg = ax.get_legend()
2935
+ if leg is not None:
2936
+ leg.set_visible(False)
2937
+ except Exception:
2938
+ pass
2939
+ try:
2940
+ fig.canvas.draw()
2941
+ except Exception:
2942
+ try:
2943
+ fig.canvas.draw_idle()
2944
+ except Exception:
2945
+ pass
2946
+ return fig, ax, ax2, sc_charge, sc_discharge, sc_eff, file_data
2947
+ except Exception as e:
2948
+ import traceback
2949
+ print(f"Error loading CPC session: {e}")
2950
+ traceback.print_exc()
2951
+ return None