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