batplot 1.8.1__py3-none-any.whl → 1.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of batplot might be problematic. Click here for more details.
- batplot/__init__.py +1 -1
- batplot/args.py +2 -0
- batplot/batplot.py +44 -4
- batplot/cpc_interactive.py +10 -0
- batplot/interactive.py +10 -0
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +17 -0
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/METADATA +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/RECORD +38 -15
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/top_level.txt +1 -0
- batplot_backup_20251221_101150/__init__.py +5 -0
- batplot_backup_20251221_101150/args.py +625 -0
- batplot_backup_20251221_101150/batch.py +1176 -0
- batplot_backup_20251221_101150/batplot.py +3589 -0
- batplot_backup_20251221_101150/cif.py +823 -0
- batplot_backup_20251221_101150/cli.py +149 -0
- batplot_backup_20251221_101150/color_utils.py +547 -0
- batplot_backup_20251221_101150/config.py +198 -0
- batplot_backup_20251221_101150/converters.py +204 -0
- batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
- batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
- batplot_backup_20251221_101150/interactive.py +3894 -0
- batplot_backup_20251221_101150/manual.py +323 -0
- batplot_backup_20251221_101150/modes.py +799 -0
- batplot_backup_20251221_101150/operando.py +603 -0
- batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
- batplot_backup_20251221_101150/plotting.py +228 -0
- batplot_backup_20251221_101150/readers.py +2607 -0
- batplot_backup_20251221_101150/session.py +2951 -0
- batplot_backup_20251221_101150/style.py +1441 -0
- batplot_backup_20251221_101150/ui.py +790 -0
- batplot_backup_20251221_101150/utils.py +1046 -0
- batplot_backup_20251221_101150/version_check.py +253 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,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
|