batplot 1.8.38__tar.gz → 1.8.40__tar.gz

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.
Files changed (42) hide show
  1. {batplot-1.8.38/batplot.egg-info → batplot-1.8.40}/PKG-INFO +1 -1
  2. {batplot-1.8.38 → batplot-1.8.40}/batplot/__init__.py +1 -1
  3. {batplot-1.8.38 → batplot-1.8.40}/batplot/args.py +20 -7
  4. {batplot-1.8.38 → batplot-1.8.40}/batplot/batch.py +40 -19
  5. {batplot-1.8.38 → batplot-1.8.40}/batplot/batplot.py +70 -11
  6. {batplot-1.8.38 → batplot-1.8.40}/batplot/color_utils.py +33 -14
  7. {batplot-1.8.38 → batplot-1.8.40}/batplot/config.py +44 -0
  8. {batplot-1.8.38 → batplot-1.8.40}/batplot/cpc_interactive.py +168 -86
  9. {batplot-1.8.38 → batplot-1.8.40}/batplot/data/CHANGELOG.md +8 -0
  10. {batplot-1.8.38 → batplot-1.8.40}/batplot/electrochem_interactive.py +839 -69
  11. {batplot-1.8.38 → batplot-1.8.40}/batplot/interactive.py +161 -109
  12. {batplot-1.8.38 → batplot-1.8.40}/batplot/operando.py +106 -55
  13. {batplot-1.8.38 → batplot-1.8.40}/batplot/operando_ec_interactive.py +574 -181
  14. {batplot-1.8.38 → batplot-1.8.40}/batplot/readers.py +2 -2
  15. {batplot-1.8.38 → batplot-1.8.40}/batplot/session.py +255 -183
  16. {batplot-1.8.38 → batplot-1.8.40}/batplot/style.py +17 -51
  17. {batplot-1.8.38 → batplot-1.8.40}/batplot/ui.py +150 -1
  18. {batplot-1.8.38 → batplot-1.8.40}/batplot/utils.py +52 -0
  19. {batplot-1.8.38 → batplot-1.8.40/batplot.egg-info}/PKG-INFO +1 -1
  20. {batplot-1.8.38 → batplot-1.8.40}/pyproject.toml +1 -1
  21. {batplot-1.8.38 → batplot-1.8.40}/LICENSE +0 -0
  22. {batplot-1.8.38 → batplot-1.8.40}/MANIFEST.in +0 -0
  23. {batplot-1.8.38 → batplot-1.8.40}/NOTICE +0 -0
  24. {batplot-1.8.38 → batplot-1.8.40}/README.md +0 -0
  25. {batplot-1.8.38 → batplot-1.8.40}/batplot/canvas_interactive.py +0 -0
  26. {batplot-1.8.38 → batplot-1.8.40}/batplot/cif.py +0 -0
  27. {batplot-1.8.38 → batplot-1.8.40}/batplot/cli.py +0 -0
  28. {batplot-1.8.38 → batplot-1.8.40}/batplot/converters.py +0 -0
  29. {batplot-1.8.38 → batplot-1.8.40}/batplot/data/USER_MANUAL.md +0 -0
  30. {batplot-1.8.38 → batplot-1.8.40}/batplot/dev_upgrade.py +0 -0
  31. {batplot-1.8.38 → batplot-1.8.40}/batplot/manual.py +0 -0
  32. {batplot-1.8.38 → batplot-1.8.40}/batplot/modes.py +0 -0
  33. {batplot-1.8.38 → batplot-1.8.40}/batplot/plotting.py +0 -0
  34. {batplot-1.8.38 → batplot-1.8.40}/batplot/showcol.py +0 -0
  35. {batplot-1.8.38 → batplot-1.8.40}/batplot/version_check.py +0 -0
  36. {batplot-1.8.38 → batplot-1.8.40}/batplot.egg-info/SOURCES.txt +0 -0
  37. {batplot-1.8.38 → batplot-1.8.40}/batplot.egg-info/dependency_links.txt +0 -0
  38. {batplot-1.8.38 → batplot-1.8.40}/batplot.egg-info/entry_points.txt +0 -0
  39. {batplot-1.8.38 → batplot-1.8.40}/batplot.egg-info/requires.txt +0 -0
  40. {batplot-1.8.38 → batplot-1.8.40}/batplot.egg-info/top_level.txt +0 -0
  41. {batplot-1.8.38 → batplot-1.8.40}/setup.cfg +0 -0
  42. {batplot-1.8.38 → batplot-1.8.40}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: batplot
3
- Version: 1.8.38
3
+ Version: 1.8.40
4
4
  Summary: Interactive plotting tool for material science (1D plot) and electrochemistry (GC, CV, dQ/dV, CPC, operando) with batch processing
5
5
  Author-email: Tian Dai <tianda@uio.no>
6
6
  License: MIT License
@@ -1,5 +1,5 @@
1
1
  """batplot: Interactive plotting for battery data visualization."""
2
2
 
3
- __version__ = "1.8.38"
3
+ __version__ = "1.8.40"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -37,6 +37,8 @@ import re
37
37
  #
38
38
  # If rich is not installed, we fall back to plain text (still works fine).
39
39
  # ====================================================================
40
+ from .utils import parse_mass_mg_from_cli
41
+
40
42
  try:
41
43
  from rich.console import Console # type: ignore[import]
42
44
  from rich.markup import escape # type: ignore[import]
@@ -234,6 +236,8 @@ def _print_xy_help() -> None:
234
236
  " batplot f1.txt --readcol 2 3 f2.txt --readcol 5 6 --convert 1.54 q\n"
235
237
  " Directory: pass a folder to convert all .xy/.xye/.qye/.dat/.csv/.txt files:\n"
236
238
  " batplot /path/to/folder --convert 0.25448 1.54\n"
239
+ " Batch in current folder: use allfiles token (non-convertible files are skipped):\n"
240
+ " batplot allfiles --convert q 1.54\n"
237
241
  " Examples:\n"
238
242
  " batplot file.xye --convert 1.54 0.25\n"
239
243
  " batplot file.xye --convert 1.54 q\n"
@@ -276,8 +280,9 @@ def _print_ec_help() -> None:
276
280
  " • Neware: Customized report — check all boxes\n"
277
281
  " • Biologic: Export all info to .mpt file\n\n"
278
282
  "Use --i for styling, colors, line widths, axis scales, etc.\n"
279
- "GC from .mpt: requires active mass in mg to compute mAh g⁻¹.\n"
280
- " batplot --gc file.mpt --mass 6.5 --i\n\n"
283
+ "GC from .mpt or .npt: requires active mass to compute mAh g⁻¹ (default unit: mg; use a ``g`` suffix for grams, e.g. --mass 0.0065g).\n"
284
+ " batplot --gc file.mpt --mass 6.5 --i\n"
285
+ " batplot --gc file.npt --mass 10g --i\n\n"
281
286
  "GC from supported .csv: specific capacity read directly when available; use --mass for\n"
282
287
  " Neware absolute-capacity files (Cycle Index / Step Index / DataPoint format).\n"
283
288
  " batplot --gc file.csv\n"
@@ -287,9 +292,11 @@ def _print_ec_help() -> None:
287
292
  " batplot f1.csv --mass 3.52 f2.mpt --mass 5.0 --cpc\n"
288
293
  " # Files without --mass between them use the global --mass value (or none)\n"
289
294
  " # Single --mass applies to all files: batplot f1.mpt f2.mpt --gc --mass 7.0\n\n"
290
- "dQ/dV from supported .csv (pre-calculated column or computed from GC data):\n"
295
+ "dQ/dV from supported .csv (pre-calculated column or computed from GC data), or from Biologic .mpt/.npt (numerical dQ/dV from GC; requires --mass):\n"
291
296
  " batplot --dqdv file.csv\n"
292
- " batplot --dqdv file.csv --mass 3.52 # Neware absolute-capacity CSV\n\n"
297
+ " batplot --dqdv file.csv --mass 3.52 # Neware absolute-capacity CSV\n"
298
+ " batplot --dqdv file.mpt --mass 6.5\n"
299
+ " batplot --dqdv file.npt --mass 0.01g\n\n"
293
300
  "Cyclic voltammetry (CV) from .mpt or .txt: plots potential vs current for each cycle.\n"
294
301
  " batplot --cv file.mpt\n"
295
302
  " batplot --cv file.txt\n\n"
@@ -298,7 +305,7 @@ def _print_ec_help() -> None:
298
305
  " batplot --cpc file.csv # Neware CSV (specific capacity)\n"
299
306
  " batplot --cpc file.csv --mass 3.52 # Neware absolute-capacity CSV\n"
300
307
  " batplot --cpc file.xlsx # Landt/Lanhe Excel (Chinese tester)\n"
301
- " batplot --cpc file.mpt --mass 1.2 # Biologic MPT\n"
308
+ " batplot --cpc file.mpt --mass 1.2 # Biologic .mpt / .npt\n"
302
309
  " batplot file1.csv --mass 3.52 file2.mpt --mass 1.2 --cpc # Per-file mass\n"
303
310
  " batplot --cpc file1.csv file2.xlsx file3.mpt --mass 1.2 --i\n\n"
304
311
  "Excel support: Landt/Lanhe (蓝电/蓝河) .xlsx files with Chinese headers:\n"
@@ -340,6 +347,8 @@ def _print_op_help() -> None:
340
347
  " batplot --operando --xaxis 2theta # Using 2theta axis\n"
341
348
  " batplot --operando --1d --i # Plot derivatives as contour with interactive menu\n"
342
349
  " batplot --operando --2d --i # Plot derivatives (alias for --1d)\n\n"
350
+ " batplot --operando --average 2 --i # Average every 2 scans before contouring\n\n"
351
+ " batplot --operando --sum 2 --i # Sum every 2 scans to boost intensity\n\n"
343
352
  "Bruker operando (.brml):\n"
344
353
  " • Place .brml files (e.g. XX_cyc1.brml, XX_cyc2.brml) in the folder.\n"
345
354
  " • Each .brml is expanded into per-scan rows; files sorted by cyc1/cyc2/cyc3.\n"
@@ -353,6 +362,8 @@ def _print_op_help() -> None:
353
362
  " • If a .mpt file is present, a side panel is added for dual-panel mode (time/potential/temp/etc.).\n"
354
363
  " • Without a .mpt file, operando-only mode shows the contour plot alone.\n"
355
364
  " • --1d / --2d: plot the first derivative (dy/dx) of each scan as a contour plot.\n\n"
365
+ " • --average N: average every N consecutive scans to improve S/N (e.g., N=2 averages scans 1+2, 3+4, ...).\n\n"
366
+ " • --sum N: sum every N consecutive scans (same binning as --average, but without division by N).\n\n"
356
367
  "Column selection (operando-specific):\n"
357
368
  " --readcolc <x> <y> : columns for contour plot (x,y in .xy/.xye/.qye/.dat files)\n"
358
369
  " --readcols <x> <y> : columns for side panel (x,y in .mpt file)\n"
@@ -382,7 +393,7 @@ def build_parser() -> argparse.ArgumentParser:
382
393
  --------------
383
394
  - Positional arguments: 'files' - list of file paths (can be 0 or more)
384
395
  - Flags (boolean): '--i' - True if present, False if absent
385
- - Options with values: '--mass 7.0' - requires a value (float in this case)
396
+ - Options with values: '--mass 7.0' or '--mass 0.01g' - mass in mg, or grams with a ``g`` suffix
386
397
  - Optional arguments: '--help xy' - can have optional value
387
398
 
388
399
  WHY add_help=False?
@@ -459,9 +470,11 @@ def build_parser() -> argparse.ArgumentParser:
459
470
  parser.add_argument("--ry", action="store_true", help=argparse.SUPPRESS)
460
471
  parser.add_argument("--txaxis", action="store_true", help=argparse.SUPPRESS)
461
472
  parser.add_argument("--operando", "--contour", action="store_true", dest="operando", help=argparse.SUPPRESS)
473
+ parser.add_argument("--average", type=int, help=argparse.SUPPRESS)
474
+ parser.add_argument("--sum", dest="scan_sum", type=int, help=argparse.SUPPRESS)
462
475
  parser.add_argument("--debug", action="store_true", help=argparse.SUPPRESS)
463
476
  parser.add_argument("--gc", action="store_true", help=argparse.SUPPRESS)
464
- parser.add_argument("--mass", type=float, action='append', help=argparse.SUPPRESS)
477
+ parser.add_argument("--mass", type=parse_mass_mg_from_cli, action='append', help=argparse.SUPPRESS)
465
478
  parser.add_argument("--dqdv", action="store_true", help=argparse.SUPPRESS)
466
479
  parser.add_argument("--cv", action="store_true", help=argparse.SUPPRESS)
467
480
  parser.add_argument("--cpc", action="store_true", help=argparse.SUPPRESS)
@@ -28,6 +28,13 @@ from .readers import (
28
28
  read_biologic_txt_file,
29
29
  )
30
30
 
31
+ # BioLogic EC-Lab ASCII exports use ``.mpt``; ``.npt`` is accepted as the same format.
32
+ _MPT_LIKE_EXTS = frozenset({'.mpt', '.npt'})
33
+
34
+
35
+ def _is_mpt_like_ext(ext: str) -> bool:
36
+ return (ext or '').lower() in _MPT_LIKE_EXTS
37
+
31
38
 
32
39
  def _resolve_mass(mass_arg, file_idx: int = 0):
33
40
  """Return mass (mg) for file at file_idx from a --mass list or single value."""
@@ -488,7 +495,7 @@ def batch_process(directory: str, args):
488
495
  known_ext = {'.xye', '.xy', '.qye', '.dat', '.csv', '.gr', '.nor', '.chik', '.chir', '.txt', '.brml', '.raw', '.xrdml', '.rasx'}
489
496
 
490
497
  # Extensions to exclude (not data files, or require special handling)
491
- excluded_ext = {'.cif', '.pkl', '.py', '.md', '.json', '.yml', '.yaml', '.sh', '.bat', '.mpt'}
498
+ excluded_ext = {'.cif', '.pkl', '.py', '.md', '.json', '.yml', '.yaml', '.sh', '.bat', '.mpt', '.npt'}
492
499
 
493
500
  # Create output directory for saved plots
494
501
  out_dir = ensure_subdirectory('Figures', directory)
@@ -842,7 +849,7 @@ def batch_process(directory: str, args):
842
849
  def batch_process_ec(directory: str, args):
843
850
  """Batch process electrochemistry files in a directory.
844
851
 
845
- Supports GC (.mpt/.csv), CV (.mpt), dQdV (.csv), and CPC (.mpt/.csv) modes.
852
+ Supports GC (.mpt/.npt/.csv), CV (.mpt/.npt/.txt), dQ/dV (.mpt/.npt/.csv), and CPC (.mpt/.npt/.csv) modes.
846
853
  Exports SVG plots to batplot_svg subdirectory.
847
854
 
848
855
  Can apply style/geometry from .bps/.bpsg files using --all flag:
@@ -852,7 +859,7 @@ def batch_process_ec(directory: str, args):
852
859
  batplot --all --cpc config.bpsg # Apply to all CPC files
853
860
 
854
861
  Note: For GC and CPC modes with .csv files, --mass is not required as the
855
- capacity data is already in the file. For .mpt files, --mass is required.
862
+ capacity data is already in the file. For .mpt/.npt files, --mass is required (mg by default; use a ``g`` suffix for grams, e.g. ``--mass 0.01g``).
856
863
 
857
864
  Args:
858
865
  directory: Directory containing EC files
@@ -906,19 +913,19 @@ def batch_process_ec(directory: str, args):
906
913
  mode = None
907
914
  if getattr(args, 'gc', False):
908
915
  mode = 'gc'
909
- supported_ext = {'.mpt', '.csv'}
916
+ supported_ext = {'.mpt', '.npt', '.csv'}
910
917
  elif getattr(args, 'cv', False):
911
918
  mode = 'cv'
912
- supported_ext = {'.mpt', '.txt'}
919
+ supported_ext = {'.mpt', '.npt', '.txt'}
913
920
  elif getattr(args, 'dqdv', False):
914
921
  mode = 'dqdv'
915
- supported_ext = {'.csv'}
922
+ supported_ext = {'.mpt', '.npt', '.csv'}
916
923
  elif getattr(args, 'cpc', False):
917
924
  mode = 'cpc'
918
- supported_ext = {'.mpt', '.csv'}
925
+ supported_ext = {'.mpt', '.npt', '.csv'}
919
926
  elif getattr(args, 'epc', False):
920
927
  mode = 'epc'
921
- supported_ext = {'.mpt', '.csv'}
928
+ supported_ext = {'.mpt', '.npt', '.csv'}
922
929
  else:
923
930
  print("EC batch mode requires one of: --gc, --cv, --dqdv, or --cpc")
924
931
  return
@@ -1007,9 +1014,9 @@ def batch_process_ec(directory: str, args):
1007
1014
 
1008
1015
  # ---- GC Mode ----
1009
1016
  if mode == 'gc':
1010
- if ext == '.mpt':
1017
+ if _is_mpt_like_ext(ext):
1011
1018
  if mass_mg is None:
1012
- print(f" Skipped {fname}: GC mode (.mpt) requires --mass parameter")
1019
+ print(f" Skipped {fname}: GC mode (.mpt/.npt) requires --mass parameter")
1013
1020
  plt.close(fig_b)
1014
1021
  continue
1015
1022
  specific_capacity, voltage, cycle_numbers, charge_mask, discharge_mask = cast(
@@ -1089,13 +1096,13 @@ def batch_process_ec(directory: str, args):
1089
1096
  elif mode == 'cv':
1090
1097
  if ext == '.txt':
1091
1098
  voltage, current, cycles = read_biologic_txt_file(fpath, mode='cv')
1092
- elif ext == '.mpt':
1099
+ elif _is_mpt_like_ext(ext):
1093
1100
  voltage, current, cycles = cast(
1094
1101
  Tuple[np.ndarray, np.ndarray, np.ndarray],
1095
1102
  read_mpt_file(fpath, mode='cv'),
1096
1103
  )
1097
1104
  else:
1098
- raise ValueError("CV mode requires .mpt or .txt file")
1105
+ raise ValueError("CV mode requires .mpt, .npt, or .txt file")
1099
1106
 
1100
1107
  cyc_int_raw = np.array(np.rint(cycles), dtype=int)
1101
1108
  if cyc_int_raw.size:
@@ -1125,13 +1132,27 @@ def batch_process_ec(directory: str, args):
1125
1132
 
1126
1133
  # ---- dQdV Mode ----
1127
1134
  elif mode == 'dqdv':
1128
- if ext != '.csv':
1129
- raise ValueError("dQdV mode requires .csv file")
1130
-
1131
1135
  # Try to load pre-calculated dQ/dV columns; fall back to numerical computation
1132
1136
  _b_dqdv_header = None
1133
1137
  _b_loaded = False
1134
- if is_biologic_datalogger_csv(fpath):
1138
+ if _is_mpt_like_ext(ext):
1139
+ if mass_mg is None or mass_mg <= 0:
1140
+ print(f" Skipped {fname}: dQ/dV (.mpt/.npt) requires --mass parameter")
1141
+ plt.close(fig_b)
1142
+ continue
1143
+ _b_gc_cap, _b_gc_volt, _b_gc_cyc, _b_gc_chgm, _b_gc_dchm = cast(
1144
+ Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray],
1145
+ read_mpt_file(fpath, mode='gc', mass_mg=mass_mg),
1146
+ )
1147
+ voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = \
1148
+ compute_dqdv_numerical(
1149
+ _b_gc_cap, _b_gc_volt, _b_gc_cyc, _b_gc_chgm, _b_gc_dchm,
1150
+ )
1151
+ _b_loaded = True
1152
+ print(f"dQ/dV batch: computing numerically from GC data for {fname!r}.")
1153
+ elif ext != '.csv':
1154
+ raise ValueError(f"dQ/dV mode requires .csv or Biologic .mpt/.npt file, got {ext}")
1155
+ elif is_biologic_datalogger_csv(fpath):
1135
1156
  if mass_mg is None or mass_mg <= 0:
1136
1157
  print(f" Skipped {fname}: dQ/dV (Biologic DataLogger CSV) requires --mass parameter")
1137
1158
  plt.close(fig_b)
@@ -1164,7 +1185,7 @@ def batch_process_ec(directory: str, args):
1164
1185
  if _b_mass and _b_mass > 0:
1165
1186
  _b_gc_cap = _b_gc_cap * (1000.0 / float(_b_mass))
1166
1187
  else:
1167
- print(f"dQ/dV batch: {fname!r} — pass --mass <mg> for specific dQ/dV.")
1188
+ print(f"dQ/dV batch: {fname!r} — pass --mass (mg, or e.g. 0.01g) for specific dQ/dV.")
1168
1189
  voltage, dqdv, cycles, charge_mask, discharge_mask, y_label = \
1169
1190
  compute_dqdv_numerical(_b_gc_cap, _b_gc_volt, _b_gc_cyc, _b_gc_chgm, _b_gc_dchm)
1170
1191
  print(f"dQ/dV batch: computing numerically from GC data for {fname!r}.")
@@ -1219,9 +1240,9 @@ def batch_process_ec(directory: str, args):
1219
1240
 
1220
1241
  # ---- CPC / EPC Mode ----
1221
1242
  elif mode in ('cpc', 'epc'):
1222
- if ext == '.mpt':
1243
+ if _is_mpt_like_ext(ext):
1223
1244
  if mass_mg is None:
1224
- print(f" Skipped {fname}: {mode.upper()} mode (.mpt) requires --mass parameter")
1245
+ print(f" Skipped {fname}: {mode.upper()} mode (.mpt/.npt) requires --mass parameter")
1225
1246
  plt.close(fig_b)
1226
1247
  continue
1227
1248
  if mode == 'cpc':
@@ -106,6 +106,39 @@ try:
106
106
  except ImportError:
107
107
  operando_ec_interactive_menu = None
108
108
 
109
+
110
+ def _run_saved_dqdv_2d_companion(fig, sess_path: str) -> None:
111
+ """If load_ec_session attached a saved dQ/dV 2D bundle, run its operando menu after the EC menu."""
112
+ b = getattr(fig, "_dqdv_2d_companion_bundle", None)
113
+ if not b or len(b) < 4:
114
+ return
115
+ cfig, cax, im, cbar = b[0], b[1], b[2], b[3]
116
+ if operando_ec_interactive_menu is None:
117
+ print("Operando interactive not available; skipping saved dQ/dV 2D map.")
118
+ return
119
+ paths = list(getattr(fig, "_bp_source_paths", []) or [])
120
+ if not paths:
121
+ paths = [sess_path]
122
+ print("\nSession includes a saved dQ/dV 2D map — opening contour interactive menu (q exits to finish).")
123
+ try:
124
+ plt.show(block=False)
125
+ except Exception:
126
+ pass
127
+ try:
128
+ operando_ec_interactive_menu(cfig, cax, im, cbar, None, file_paths=paths, canvas_mode=False)
129
+ except Exception as e:
130
+ print(f"Saved dQ/dV 2D map menu failed: {e}")
131
+ finally:
132
+ try:
133
+ plt.close(cfig)
134
+ except Exception:
135
+ pass
136
+ try:
137
+ delattr(fig, "_dqdv_2d_companion_bundle")
138
+ except Exception:
139
+ pass
140
+
141
+
109
142
  try:
110
143
  from .cpc_interactive import cpc_interactive_menu, _generate_similar_color, _build_compact_cpc_legend
111
144
  except ImportError:
@@ -2624,7 +2657,11 @@ def batplot_main() -> int: # type: ignore
2624
2657
  expanded = []
2625
2658
  for p in args.files:
2626
2659
  if os.path.isfile(p):
2627
- expanded.append(p)
2660
+ ext = os.path.splitext(p)[1].lower()
2661
+ if ext in convert_ext:
2662
+ expanded.append(p)
2663
+ else:
2664
+ print(f"Warning: Skipping non-convertible file: {p}")
2628
2665
  elif os.path.isdir(p):
2629
2666
  for f in sorted(os.listdir(p), key=natural_sort_key):
2630
2667
  fp = os.path.join(p, f)
@@ -2786,6 +2823,10 @@ def batplot_main() -> int: # type: ignore
2786
2823
  electrochem_interactive_menu(fig, ax, cycle_lines, file_path=sess_path)
2787
2824
  except Exception as _ie:
2788
2825
  print(f"Interactive menu failed: {_ie}")
2826
+ try:
2827
+ _run_saved_dqdv_2d_companion(fig, sess_path)
2828
+ except Exception as _c2d:
2829
+ print(f"Saved dQ/dV 2D companion: {_c2d}")
2789
2830
  plt.show()
2790
2831
  exit()
2791
2832
  except Exception as e:
@@ -3351,6 +3392,10 @@ def batplot_main() -> int: # type: ignore
3351
3392
  stack_label_bottom = bool(sess.get('stack_label_at_bottom', False))
3352
3393
  update_labels(ax, y_data_list, label_text_objects, saved_stack, stack_label_bottom)
3353
3394
  if cif_tick_series:
3395
+ try:
3396
+ fig._batplot_cif_tick_series = cif_tick_series
3397
+ except Exception:
3398
+ pass
3354
3399
  # Provide draw/extend helpers compatible with interactive menu using original placement logic
3355
3400
  def _session_q_to_2theta(peaksQ, wl):
3356
3401
  if wl is None:
@@ -3364,7 +3409,8 @@ def batplot_main() -> int: # type: ignore
3364
3409
 
3365
3410
  def _session_ensure_wavelength(default_wl=1.5406):
3366
3411
  # Prefer any stored wl, else args.wl, else provided default
3367
- for _lab,_fname,_peaks,_wl,_qmax,_color in cif_tick_series:
3412
+ _ser = getattr(fig, '_batplot_cif_tick_series', None) or cif_tick_series
3413
+ for _lab,_fname,_peaks,_wl,_qmax,_color in _ser:
3368
3414
  if _wl is not None:
3369
3415
  return _wl
3370
3416
  return getattr(args, 'wl', None) or default_wl
@@ -3374,7 +3420,10 @@ def batplot_main() -> int: # type: ignore
3374
3420
  return
3375
3421
 
3376
3422
  def _session_cif_draw():
3377
- if not cif_tick_series:
3423
+ cif_series_draw = getattr(fig, '_batplot_cif_tick_series', None)
3424
+ if cif_series_draw is None:
3425
+ cif_series_draw = cif_tick_series
3426
+ if not cif_series_draw:
3378
3427
  return
3379
3428
  try:
3380
3429
  # Preserve current limits before drawing - use actual current limits
@@ -3435,7 +3484,7 @@ def batplot_main() -> int: # type: ignore
3435
3484
  stacked_or_multi_y=_stacked_s,
3436
3485
  )
3437
3486
  _cif_bottom_m = xy_cif_stack_bottom_margin_yr(fixed_yr, show_titles=show_titles_local)
3438
- needed_min = base - (len(cif_tick_series) - 1) * spacing - _cif_bottom_m
3487
+ needed_min = base - (len(cif_series_draw) - 1) * spacing - _cif_bottom_m
3439
3488
  if not show_titles_local:
3440
3489
  ylim_draw = tuple(prev_ylim)
3441
3490
  elif needed_min >= prev_ylim[0]:
@@ -3457,7 +3506,7 @@ def batplot_main() -> int: # type: ignore
3457
3506
  wl_any = _session_ensure_wavelength()
3458
3507
 
3459
3508
  # Draw each series
3460
- for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
3509
+ for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_series_draw):
3461
3510
  y_line = base - i * spacing + xy_cif_stack_y_offset(fig, i)
3462
3511
  tick_h, hkl_y = xy_cif_tick_stack_layout(y_line, yr)
3463
3512
  # Convert peaks to axis domain
@@ -3621,7 +3670,7 @@ def batplot_main() -> int: # type: ignore
3621
3670
  cif_globals_dict = None
3622
3671
  if cif_tick_series:
3623
3672
  cif_globals_dict = {
3624
- 'cif_tick_series': list(cif_tick_series),
3673
+ 'cif_tick_series': cif_tick_series,
3625
3674
  'cif_hkl_map': cif_hkl_map,
3626
3675
  'cif_hkl_label_map': cif_hkl_label_map,
3627
3676
  'show_cif_hkl': bool(show_cif_hkl),
@@ -4457,7 +4506,13 @@ def batplot_main() -> int: # type: ignore
4457
4506
  draw_cif_ticks()
4458
4507
 
4459
4508
  def draw_cif_ticks():
4460
- if not cif_tick_series:
4509
+ # Interactive menu mutates _bp.cif_tick_series; session/menu paths may use a
4510
+ # different list than this closure. fig._batplot_cif_tick_series stays synced
4511
+ # from interactive_menu so redraw sees renames (r→t), reorder, colors, etc.
4512
+ cif_series_draw = getattr(fig, '_batplot_cif_tick_series', None)
4513
+ if cif_series_draw is None:
4514
+ cif_series_draw = cif_tick_series
4515
+ if not cif_series_draw:
4461
4516
  return
4462
4517
  # Preserve current limits before drawing - use actual current limits
4463
4518
  # to prevent any movement when toggling
@@ -4492,13 +4547,13 @@ def batplot_main() -> int: # type: ignore
4492
4547
  _bp_module = sys.modules.get('__main__')
4493
4548
  if _bp_module is not None and hasattr(_bp_module, 'cif_set_visible'):
4494
4549
  vis = list(getattr(_bp_module, 'cif_set_visible') or [])
4495
- if len(vis) == len(cif_tick_series):
4550
+ if len(vis) == len(cif_series_draw):
4496
4551
  set_visible = [bool(v) for v in vis]
4497
4552
  except Exception:
4498
4553
  pass
4499
4554
  # Effective number of visible CIF rows (for spacing and y-limit expansion)
4500
4555
  if set_visible is None:
4501
- n_rows = len(cif_tick_series)
4556
+ n_rows = len(cif_series_draw)
4502
4557
  else:
4503
4558
  n_rows = max(1, sum(1 for v in set_visible if v))
4504
4559
 
@@ -4571,7 +4626,7 @@ def batplot_main() -> int: # type: ignore
4571
4626
  except Exception:
4572
4627
  pass
4573
4628
  visible_idx = 0
4574
- for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
4629
+ for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_series_draw):
4575
4630
  if set_visible is not None and i < len(set_visible) and not set_visible[i]:
4576
4631
  continue
4577
4632
  y_line = base - visible_idx * spacing + xy_cif_stack_y_offset(fig, i)
@@ -4651,7 +4706,7 @@ def batplot_main() -> int: # type: ignore
4651
4706
  hover_meta = []
4652
4707
  show_hkl = globals().get('show_cif_hkl', False)
4653
4708
  # Build mapping from Q to label text if available
4654
- for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
4709
+ for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_series_draw):
4655
4710
  if use_2th and wl is None:
4656
4711
  wl = getattr(ax, '_cif_hover_wl', None)
4657
4712
  # Recreate domain peaks consistent with those drawn (limit to view)
@@ -4738,6 +4793,10 @@ def batplot_main() -> int: # type: ignore
4738
4793
  ax._cif_hover_cid = cid
4739
4794
 
4740
4795
  if cif_tick_series:
4796
+ try:
4797
+ fig._batplot_cif_tick_series = cif_tick_series
4798
+ except Exception:
4799
+ pass
4741
4800
  # Auto-assign distinct colors for CIF tick series.
4742
4801
  # For multiple CIF series:
4743
4802
  # - If <= 10 files, use 'tab10' but in a re-ordered sequence to
@@ -104,6 +104,37 @@ def ensure_colormap(name: Optional[str]) -> bool:
104
104
  """
105
105
  if not name:
106
106
  return False
107
+
108
+ def _register_cmap_safe(cmap_name: str, cmap_obj) -> bool:
109
+ """Register cmap across matplotlib API variants."""
110
+ # matplotlib >= 3.5 style registry API
111
+ try:
112
+ reg = getattr(plt, "colormaps", None)
113
+ if reg is not None and hasattr(reg, "register"):
114
+ try:
115
+ reg.register(cmap_obj, name=cmap_name, force=True)
116
+ except TypeError:
117
+ # Older signature may not support force kwarg
118
+ reg.register(cmap_obj, name=cmap_name)
119
+ return True
120
+ except Exception:
121
+ pass
122
+ # Older pyplot API fallback
123
+ try:
124
+ if hasattr(plt, "register_cmap"):
125
+ plt.register_cmap(name=cmap_name, cmap=cmap_obj)
126
+ return True
127
+ except ValueError:
128
+ # Already registered
129
+ return True
130
+ except Exception:
131
+ pass
132
+ # As a last resort, accept cmap object usability even if registration fails.
133
+ try:
134
+ _ = cmap_obj(0.5)
135
+ return True
136
+ except Exception:
137
+ return False
107
138
 
108
139
  # Handle reversed colormaps (remove '_r' suffix to get base name)
109
140
  # Example: 'viridis_r' → base = 'viridis', we'll reverse it later if needed
@@ -120,13 +151,7 @@ def ensure_colormap(name: Optional[str]) -> bool:
120
151
  import cmcrameri.cm as cmc
121
152
  if hasattr(cmc, base_lower):
122
153
  cmap_obj = getattr(cmc, base_lower)
123
- try:
124
- # Register it with matplotlib so it can be used like built-in colormaps
125
- plt.register_cmap(name=base_lower, cmap=cmap_obj)
126
- except ValueError:
127
- # Already registered, that's fine
128
- pass
129
- return True
154
+ return _register_cmap_safe(base_lower, cmap_obj)
130
155
  except Exception:
131
156
  # cmcrameri not installed or colormap not found, continue to next step
132
157
  pass
@@ -139,13 +164,7 @@ def ensure_colormap(name: Optional[str]) -> bool:
139
164
  # N=256 means create 256 intermediate colors by interpolating between the given colors
140
165
  # This creates a smooth gradient
141
166
  cmap_obj = LinearSegmentedColormap.from_list(base_lower, custom, N=256)
142
- try:
143
- # Register with matplotlib
144
- plt.register_cmap(name=base_lower, cmap=cmap_obj)
145
- except ValueError:
146
- # Already registered, that's fine
147
- pass
148
- return True
167
+ return _register_cmap_safe(base_lower, cmap_obj)
149
168
  except Exception:
150
169
  return False
151
170
 
@@ -18,6 +18,10 @@ Example config.json structure:
18
18
  "#0000FF",
19
19
  "red",
20
20
  "blue"
21
+ ],
22
+ "recent_axis_names": [
23
+ "Potential (V)",
24
+ "dQ/dV (mAh V$^{-1}$)"
21
25
  ]
22
26
  }
23
27
 
@@ -192,7 +196,47 @@ def save_user_colors(colors: List[str]) -> None:
192
196
  save_config(config) # Save entire config back to file
193
197
 
194
198
 
199
+ _RECENT_AXIS_NAMES_KEY = 'recent_axis_names'
200
+ RECENT_AXIS_NAMES_MAX = 20
201
+
202
+
203
+ def get_recent_axis_names() -> List[str]:
204
+ """Return up to :data:`RECENT_AXIS_NAMES_MAX` recently typed axis labels (newest first)."""
205
+ raw = load_config().get(_RECENT_AXIS_NAMES_KEY, [])
206
+ if not isinstance(raw, list):
207
+ return []
208
+ out: List[str] = []
209
+ for item in raw:
210
+ s = str(item).strip()
211
+ if s and s not in out:
212
+ out.append(s)
213
+ if len(out) >= RECENT_AXIS_NAMES_MAX:
214
+ break
215
+ return out
216
+
217
+
218
+ def record_recent_axis_name(name: str) -> None:
219
+ """Add an axis label to the shared recent list (dedupe, newest first, max 20)."""
220
+ s = str(name or '').strip()
221
+ if not s:
222
+ return
223
+ config = load_config()
224
+ raw = config.get(_RECENT_AXIS_NAMES_KEY, [])
225
+ names: List[str] = []
226
+ if isinstance(raw, list):
227
+ for item in raw:
228
+ t = str(item).strip()
229
+ if t and t != s:
230
+ names.append(t)
231
+ names.insert(0, s)
232
+ config[_RECENT_AXIS_NAMES_KEY] = names[:RECENT_AXIS_NAMES_MAX]
233
+ save_config(config)
234
+
235
+
195
236
  __all__ = [
196
237
  'get_user_colors',
197
238
  'save_user_colors',
239
+ 'get_recent_axis_names',
240
+ 'record_recent_axis_name',
241
+ 'RECENT_AXIS_NAMES_MAX',
198
242
  ]