batplot 1.8.39__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.39/batplot.egg-info → batplot-1.8.40}/PKG-INFO +1 -1
  2. {batplot-1.8.39 → batplot-1.8.40}/batplot/__init__.py +1 -1
  3. {batplot-1.8.39 → batplot-1.8.40}/batplot/args.py +18 -7
  4. {batplot-1.8.39 → batplot-1.8.40}/batplot/batch.py +40 -19
  5. {batplot-1.8.39 → batplot-1.8.40}/batplot/batplot.py +37 -0
  6. {batplot-1.8.39 → batplot-1.8.40}/batplot/color_utils.py +33 -14
  7. {batplot-1.8.39 → batplot-1.8.40}/batplot/config.py +44 -0
  8. {batplot-1.8.39 → batplot-1.8.40}/batplot/cpc_interactive.py +124 -51
  9. {batplot-1.8.39 → batplot-1.8.40}/batplot/data/CHANGELOG.md +4 -0
  10. {batplot-1.8.39 → batplot-1.8.40}/batplot/electrochem_interactive.py +839 -69
  11. {batplot-1.8.39 → batplot-1.8.40}/batplot/interactive.py +37 -48
  12. {batplot-1.8.39 → batplot-1.8.40}/batplot/operando.py +44 -0
  13. {batplot-1.8.39 → batplot-1.8.40}/batplot/operando_ec_interactive.py +574 -181
  14. {batplot-1.8.39 → batplot-1.8.40}/batplot/readers.py +2 -2
  15. {batplot-1.8.39 → batplot-1.8.40}/batplot/session.py +255 -183
  16. {batplot-1.8.39 → batplot-1.8.40}/batplot/style.py +4 -50
  17. {batplot-1.8.39 → batplot-1.8.40}/batplot/ui.py +150 -1
  18. {batplot-1.8.39 → batplot-1.8.40}/batplot/utils.py +52 -0
  19. {batplot-1.8.39 → batplot-1.8.40}/batplot/version_check.py +2 -2
  20. {batplot-1.8.39 → batplot-1.8.40/batplot.egg-info}/PKG-INFO +1 -1
  21. {batplot-1.8.39 → batplot-1.8.40}/pyproject.toml +1 -1
  22. {batplot-1.8.39 → batplot-1.8.40}/LICENSE +0 -0
  23. {batplot-1.8.39 → batplot-1.8.40}/MANIFEST.in +0 -0
  24. {batplot-1.8.39 → batplot-1.8.40}/NOTICE +0 -0
  25. {batplot-1.8.39 → batplot-1.8.40}/README.md +0 -0
  26. {batplot-1.8.39 → batplot-1.8.40}/batplot/canvas_interactive.py +0 -0
  27. {batplot-1.8.39 → batplot-1.8.40}/batplot/cif.py +0 -0
  28. {batplot-1.8.39 → batplot-1.8.40}/batplot/cli.py +0 -0
  29. {batplot-1.8.39 → batplot-1.8.40}/batplot/converters.py +0 -0
  30. {batplot-1.8.39 → batplot-1.8.40}/batplot/data/USER_MANUAL.md +0 -0
  31. {batplot-1.8.39 → batplot-1.8.40}/batplot/dev_upgrade.py +0 -0
  32. {batplot-1.8.39 → batplot-1.8.40}/batplot/manual.py +0 -0
  33. {batplot-1.8.39 → batplot-1.8.40}/batplot/modes.py +0 -0
  34. {batplot-1.8.39 → batplot-1.8.40}/batplot/plotting.py +0 -0
  35. {batplot-1.8.39 → batplot-1.8.40}/batplot/showcol.py +0 -0
  36. {batplot-1.8.39 → batplot-1.8.40}/batplot.egg-info/SOURCES.txt +0 -0
  37. {batplot-1.8.39 → batplot-1.8.40}/batplot.egg-info/dependency_links.txt +0 -0
  38. {batplot-1.8.39 → batplot-1.8.40}/batplot.egg-info/entry_points.txt +0 -0
  39. {batplot-1.8.39 → batplot-1.8.40}/batplot.egg-info/requires.txt +0 -0
  40. {batplot-1.8.39 → batplot-1.8.40}/batplot.egg-info/top_level.txt +0 -0
  41. {batplot-1.8.39 → batplot-1.8.40}/setup.cfg +0 -0
  42. {batplot-1.8.39 → 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.39
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.39"
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]
@@ -278,8 +280,9 @@ def _print_ec_help() -> None:
278
280
  " • Neware: Customized report — check all boxes\n"
279
281
  " • Biologic: Export all info to .mpt file\n\n"
280
282
  "Use --i for styling, colors, line widths, axis scales, etc.\n"
281
- "GC from .mpt: requires active mass in mg to compute mAh g⁻¹.\n"
282
- " 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"
283
286
  "GC from supported .csv: specific capacity read directly when available; use --mass for\n"
284
287
  " Neware absolute-capacity files (Cycle Index / Step Index / DataPoint format).\n"
285
288
  " batplot --gc file.csv\n"
@@ -289,9 +292,11 @@ def _print_ec_help() -> None:
289
292
  " batplot f1.csv --mass 3.52 f2.mpt --mass 5.0 --cpc\n"
290
293
  " # Files without --mass between them use the global --mass value (or none)\n"
291
294
  " # Single --mass applies to all files: batplot f1.mpt f2.mpt --gc --mass 7.0\n\n"
292
- "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"
293
296
  " batplot --dqdv file.csv\n"
294
- " 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"
295
300
  "Cyclic voltammetry (CV) from .mpt or .txt: plots potential vs current for each cycle.\n"
296
301
  " batplot --cv file.mpt\n"
297
302
  " batplot --cv file.txt\n\n"
@@ -300,7 +305,7 @@ def _print_ec_help() -> None:
300
305
  " batplot --cpc file.csv # Neware CSV (specific capacity)\n"
301
306
  " batplot --cpc file.csv --mass 3.52 # Neware absolute-capacity CSV\n"
302
307
  " batplot --cpc file.xlsx # Landt/Lanhe Excel (Chinese tester)\n"
303
- " batplot --cpc file.mpt --mass 1.2 # Biologic MPT\n"
308
+ " batplot --cpc file.mpt --mass 1.2 # Biologic .mpt / .npt\n"
304
309
  " batplot file1.csv --mass 3.52 file2.mpt --mass 1.2 --cpc # Per-file mass\n"
305
310
  " batplot --cpc file1.csv file2.xlsx file3.mpt --mass 1.2 --i\n\n"
306
311
  "Excel support: Landt/Lanhe (蓝电/蓝河) .xlsx files with Chinese headers:\n"
@@ -342,6 +347,8 @@ def _print_op_help() -> None:
342
347
  " batplot --operando --xaxis 2theta # Using 2theta axis\n"
343
348
  " batplot --operando --1d --i # Plot derivatives as contour with interactive menu\n"
344
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"
345
352
  "Bruker operando (.brml):\n"
346
353
  " • Place .brml files (e.g. XX_cyc1.brml, XX_cyc2.brml) in the folder.\n"
347
354
  " • Each .brml is expanded into per-scan rows; files sorted by cyc1/cyc2/cyc3.\n"
@@ -355,6 +362,8 @@ def _print_op_help() -> None:
355
362
  " • If a .mpt file is present, a side panel is added for dual-panel mode (time/potential/temp/etc.).\n"
356
363
  " • Without a .mpt file, operando-only mode shows the contour plot alone.\n"
357
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"
358
367
  "Column selection (operando-specific):\n"
359
368
  " --readcolc <x> <y> : columns for contour plot (x,y in .xy/.xye/.qye/.dat files)\n"
360
369
  " --readcols <x> <y> : columns for side panel (x,y in .mpt file)\n"
@@ -384,7 +393,7 @@ def build_parser() -> argparse.ArgumentParser:
384
393
  --------------
385
394
  - Positional arguments: 'files' - list of file paths (can be 0 or more)
386
395
  - Flags (boolean): '--i' - True if present, False if absent
387
- - 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
388
397
  - Optional arguments: '--help xy' - can have optional value
389
398
 
390
399
  WHY add_help=False?
@@ -461,9 +470,11 @@ def build_parser() -> argparse.ArgumentParser:
461
470
  parser.add_argument("--ry", action="store_true", help=argparse.SUPPRESS)
462
471
  parser.add_argument("--txaxis", action="store_true", help=argparse.SUPPRESS)
463
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)
464
475
  parser.add_argument("--debug", action="store_true", help=argparse.SUPPRESS)
465
476
  parser.add_argument("--gc", action="store_true", help=argparse.SUPPRESS)
466
- 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)
467
478
  parser.add_argument("--dqdv", action="store_true", help=argparse.SUPPRESS)
468
479
  parser.add_argument("--cv", action="store_true", help=argparse.SUPPRESS)
469
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:
@@ -2790,6 +2823,10 @@ def batplot_main() -> int: # type: ignore
2790
2823
  electrochem_interactive_menu(fig, ax, cycle_lines, file_path=sess_path)
2791
2824
  except Exception as _ie:
2792
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}")
2793
2830
  plt.show()
2794
2831
  exit()
2795
2832
  except Exception as e:
@@ -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
  ]