batplot 1.8.3__py3-none-any.whl → 1.8.5__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 CHANGED
@@ -1,5 +1,5 @@
1
1
  """batplot: Interactive plotting for battery data visualization."""
2
2
 
3
- __version__ = "1.8.3"
3
+ __version__ = "1.8.5"
4
4
 
5
5
  __all__ = ["__version__"]
batplot/args.py CHANGED
@@ -141,6 +141,7 @@ def _print_general_help() -> None:
141
141
  "How to run (basics):\n"
142
142
  " [1D(XY) curves]\n"
143
143
  " batplot file1.xy file2.qye [option1] [option2] # 1D curves, read the first two columns as X and Y axis by default\n"
144
+ " batplot file1.xy file2.xy --1d --stack --i # Plot 1st derivatives with interactive menu\n"
144
145
  " batplot allfiles # Plot all files in current directory on same figure\n"
145
146
  " batplot allfiles /path/to/dir # Plot all files in specified directory\n"
146
147
  " batplot allfiles --i # Plot all files with interactive menu\n"
@@ -208,7 +209,8 @@ def _print_xy_help() -> None:
208
209
  "Examples:\n"
209
210
  " batplot a.xye:1.5406 b.qye --stack --i\n"
210
211
  " batplot a.dat b.xy --wl 1.54 --i\n"
211
- " batplot pattern.qye ticks.cif:1.54 --i\n\n"
212
+ " batplot pattern.qye ticks.cif:1.54 --i\n"
213
+ " batplot file1.xy file2.xy --1d --stack --i # Plot 1st derivatives with interactive menu\n\n"
212
214
  "Plot all files together:\n"
213
215
  " batplot allfiles # Plot all XY files on same figure\n"
214
216
  " batplot allfiles /path/to/dir # Plot all XY files in specified directory\n"
@@ -241,6 +243,8 @@ def _print_xy_help() -> None:
241
243
  " --kchik : multiply y by x for EXAFS kχ(k) plots (sets labels to k (Å⁻¹) vs kχ(k) (Å⁻¹))\n"
242
244
  " --k2chik : multiply y by x² for EXAFS k²χ(k) plots (sets labels to k (Å⁻¹) vs k²χ(k) (Å⁻²))\n"
243
245
  " --k3chik : multiply y by x³ for EXAFS k³χ(k) plots (sets labels to k (Å⁻¹) vs k³χ(k) (Å⁻³))\n"
246
+ " --1d : plot the first derivative (dy/dx) of the datasets\n"
247
+ " --2d : plot the first derivative (dy/dx) of the datasets (alias for --1d)\n"
244
248
  " --xrange/-r <min> <max> : set x-axis range, e.g. --xrange 0 10\n"
245
249
  " --out/-o <filename> : save figure to file, e.g. --out file.svg\n"
246
250
  " --xaxis <type> : set x-axis type (Q, 2theta, r, k, energy, rft, time, or user defined)\n"
@@ -248,6 +252,14 @@ def _print_xy_help() -> None:
248
252
  " --ro : swap x and y axes (exchange x and y values before plotting)\n"
249
253
  " e.g. --xaxis time --ro plots time as y-axis and voltage as x-axis\n"
250
254
  " --wl <float> : set wavelength for Q conversion for all files, e.g. --wl 1.5406\n"
255
+ " --convert/-c <from> <to> : convert XRD data and export to 'converted' subfolder:\n"
256
+ " - <wl1> <wl2> : convert 2θ from wavelength1 to wavelength2\n"
257
+ " - <wl> q : convert 2θ (with wavelength) to Q space\n"
258
+ " - q <wl> : convert Q space to 2θ (with wavelength)\n"
259
+ " Examples:\n"
260
+ " batplot file.xye --convert 1.54 0.25\n"
261
+ " batplot file.xye --convert 1.54 q\n"
262
+ " batplot file.qye --convert q 1.54\n"
251
263
  " File wavelength syntax : specify wavelength(s) per file using colon syntax:\n"
252
264
  " - file:wl : single wavelength (for Q conversion or CIF 2theta calculation)\n"
253
265
  " - file:wl1:wl2 : dual wavelength (convert 2theta→Q using wl1, then Q→2theta using wl2)\n"
@@ -332,12 +344,15 @@ def _print_op_help() -> None:
332
344
  "Operando contour plots\n\n"
333
345
  "Example usage:\n"
334
346
  " batplot --operando --interactive --wl 0.25995 # Interactive mode with Q conversion\n"
335
- " batplot --operando --xaxis 2theta # Using 2theta axis\n\n"
347
+ " batplot --operando --xaxis 2theta # Using 2theta axis\n"
348
+ " batplot --operando --1d --interactive # Plot derivatives as contour with interactive menu\n"
349
+ " batplot --operando --2d --interactive # Plot derivatives (alias for --1d)\n\n"
336
350
  " • Folder should contain XY files (.xy/.xye/.qye/.dat).\n"
337
351
  " • Intensity scale is auto-adjusted between min/max values.\n"
338
352
  " • If no .qye present, provide --xaxis 2theta or set --wl for Q conversion.\n"
339
353
  " • If a .mpt file is present, an EC side panel is added for dual-panel mode.\n"
340
- " • Without a .mpt file, operando-only mode shows the contour plot alone.\n\n"
354
+ " • Without a .mpt file, operando-only mode shows the contour plot alone.\n"
355
+ " • --1d / --2d: plot the first derivative (dy/dx) of each scan as a contour plot.\n\n"
341
356
  "Interactive (--interactive): resize axes/canvas, change colormap, set intensity range (oz),\n"
342
357
  "EC y-axis options (time ↔ ions), geometry tweaks, toggle spines/ticks/labels,\n"
343
358
  "print/export/import style, save session.\n"
@@ -416,7 +431,8 @@ def build_parser() -> argparse.ArgumentParser:
416
431
  parser.add_argument("--out", "-o", type=str, help=argparse.SUPPRESS)
417
432
  parser.add_argument("--errors", action="store_true", help=argparse.SUPPRESS)
418
433
  parser.add_argument("--xaxis", type=str, help=argparse.SUPPRESS)
419
- parser.add_argument("--convert", "-c", nargs="+", help=argparse.SUPPRESS)
434
+ parser.add_argument("--convert", "-c", nargs=2, metavar=("FROM", "TO"),
435
+ help="Convert XRD data: wavelength-to-wavelength (e.g., 1.54 0.25), wavelength-to-Q (e.g., 1.54 q), or Q-to-wavelength (e.g., q 1.54). Exports to 'converted' subfolder.")
420
436
  parser.add_argument("--wl", type=float, help=argparse.SUPPRESS)
421
437
  parser.add_argument("--fullprof", nargs="+", type=float, help=argparse.SUPPRESS)
422
438
  parser.add_argument("--norm", action="store_true", help=argparse.SUPPRESS)
@@ -453,6 +469,8 @@ def build_parser() -> argparse.ArgumentParser:
453
469
  help=argparse.SUPPRESS)
454
470
  parser.add_argument("--readcolcsv", nargs=2, type=int, metavar=('X_COL', 'Y_COL'),
455
471
  help=argparse.SUPPRESS)
472
+ parser.add_argument("--1d", action="store_true", dest="derivative_1d", help=argparse.SUPPRESS)
473
+ parser.add_argument("--2d", action="store_true", dest="derivative_2d", help=argparse.SUPPRESS)
456
474
  return parser
457
475
 
458
476
 
batplot/batch.py CHANGED
@@ -646,6 +646,18 @@ def batch_process(directory: str, args):
646
646
  else:
647
647
  x_plot = x
648
648
 
649
+ # Calculate first derivative if requested (after axis conversion)
650
+ if getattr(args, 'derivative_1d', False) or getattr(args, 'derivative_2d', False):
651
+ # Calculate dy/dx using numpy gradient
652
+ # numpy.gradient handles non-uniform spacing automatically
653
+ if len(y) > 1:
654
+ dy_dx = np.gradient(y, x_plot)
655
+ y = dy_dx
656
+ else:
657
+ # Single point or empty - cannot calculate derivative
658
+ print(f"Warning: Cannot calculate derivative for {fname}: insufficient data points")
659
+ continue
660
+
649
661
  # Normalize if --norm flag is set
650
662
  if getattr(args, 'norm', False):
651
663
  if y.size:
batplot/batplot.py CHANGED
@@ -9,7 +9,7 @@ from .electrochem_interactive import electrochem_interactive_menu
9
9
  from .args import parse_args as _bp_parse_args
10
10
  from .interactive import interactive_menu
11
11
  from .batch import batch_process, batch_process_ec
12
- from .converters import convert_to_qye
12
+ from .converters import convert_xrd_data
13
13
  from .session import (
14
14
  dump_session as _bp_dump_session,
15
15
  load_ec_session,
@@ -1793,37 +1793,38 @@ def batplot_main() -> int:
1793
1793
  except ModuleNotFoundError as e:
1794
1794
  # Handle numpy._core and other module import errors
1795
1795
  if '_core' in str(e) or 'numpy' in str(e).lower():
1796
+ # Try to extract version info before the error
1797
+ from .session import _try_extract_version_from_pickle, _get_current_numpy_version
1798
+ saved_versions = _try_extract_version_from_pickle(sess_path)
1799
+ current_numpy = _get_current_numpy_version()
1800
+
1801
+ saved_numpy = saved_versions.get('numpy', 'unknown')
1802
+
1796
1803
  print(f"\nERROR: NumPy version mismatch detected when loading: {sess_path}")
1797
1804
  print("This session was saved with a different NumPy version.")
1805
+ print()
1806
+ print(f"Session was saved with: NumPy {saved_numpy}")
1807
+ print(f"Currently installed: NumPy {current_numpy}")
1808
+ print()
1798
1809
  print("The error 'No module named numpy._core' indicates:")
1799
1810
  print(" - Session saved with NumPy 2.0+ but loading with NumPy <2.0, OR")
1800
1811
  print(" - Session saved with NumPy <2.0 but loading with NumPy 2.0+")
1801
- print("\nSolutions:")
1802
- print(" 1. Check NumPy version: python3 -c 'import numpy; print(numpy.__version__)'")
1803
- print(" 2. Install matching version:")
1804
- print(" - If session was saved with NumPy 2.0+: pip install 'numpy>=2.0'")
1805
- print(" - If session was saved with NumPy <2.0: pip install 'numpy<2.0'")
1806
- print(" 3. Recreate the session from original data files")
1812
+ print()
1813
+ print("Solutions:")
1814
+ if saved_numpy != 'unknown':
1815
+ print(f" 1. Install matching version: pip install 'numpy=={saved_numpy}'")
1816
+ else:
1817
+ print(" 1. Try installing NumPy <2.0: pip install 'numpy<2.0'")
1818
+ print(" OR try installing NumPy 2.0+: pip install 'numpy>=2.0'")
1819
+ print(" 2. Recreate the session from original data files")
1807
1820
  else:
1808
1821
  print(f"\nERROR: Module import error when loading: {sess_path}")
1809
1822
  print(f"Error: {e}")
1810
1823
  print("This usually indicates a package version mismatch.")
1811
- print("Try installing matching package versions or recreate the session.")
1812
1824
  exit(1)
1813
1825
  except Exception as e:
1814
1826
  print(f"Failed to load session: {e}")
1815
1827
  exit(1)
1816
-
1817
- # Check package version compatibility (if version info is available)
1818
- try:
1819
- from .session import _get_package_versions, _check_package_compatibility
1820
- saved_versions = sess.get('package_versions', {})
1821
- current_versions = _get_package_versions()
1822
- if saved_versions and not _check_package_compatibility(saved_versions, current_versions, sess_path):
1823
- exit(1)
1824
- except Exception:
1825
- # If compatibility checking fails, continue anyway (backward compatibility)
1826
- pass
1827
1828
  # If it's an EC GC session, load and open EC interactive menu directly
1828
1829
  if isinstance(sess, dict) and sess.get('kind') == 'ec_gc':
1829
1830
  try:
@@ -1963,16 +1964,58 @@ def batplot_main() -> int:
1963
1964
  y_loaded = sess.get('y_data', []) # stored plotted (baseline+offset) values
1964
1965
  orig_loaded = sess.get('orig_y', []) # stored baseline (normalized/raw w/out offsets)
1965
1966
  offsets_saved = sess.get('offsets', [])
1967
+ # Restore processed data (for smooth/reduce operations)
1968
+ original_x_data_list = sess.get('original_x_data_list')
1969
+ original_y_data_list = sess.get('original_y_data_list')
1970
+ smooth_settings = sess.get('smooth_settings')
1971
+ if original_x_data_list is not None:
1972
+ fig._original_x_data_list = [np.array(a) for a in original_x_data_list]
1973
+ if original_y_data_list is not None:
1974
+ fig._original_y_data_list = [np.array(a) for a in original_y_data_list]
1975
+ full_processed_x_data_list = sess.get('full_processed_x_data_list')
1976
+ full_processed_y_data_list = sess.get('full_processed_y_data_list')
1977
+ if full_processed_x_data_list is not None:
1978
+ fig._full_processed_x_data_list = [np.array(a) for a in full_processed_x_data_list]
1979
+ if full_processed_y_data_list is not None:
1980
+ fig._full_processed_y_data_list = [np.array(a) for a in full_processed_y_data_list]
1981
+ if smooth_settings is not None:
1982
+ fig._smooth_settings = dict(smooth_settings)
1983
+ last_smooth_settings = sess.get('last_smooth_settings')
1984
+ if last_smooth_settings is not None:
1985
+ fig._last_smooth_settings = dict(last_smooth_settings)
1986
+ # Restore derivative data (for derivative operations)
1987
+ pre_derivative_x_data_list = sess.get('pre_derivative_x_data_list')
1988
+ pre_derivative_y_data_list = sess.get('pre_derivative_y_data_list')
1989
+ pre_derivative_ylabel = sess.get('pre_derivative_ylabel')
1990
+ derivative_order = sess.get('derivative_order')
1991
+ if pre_derivative_x_data_list is not None:
1992
+ fig._pre_derivative_x_data_list = [np.array(a) for a in pre_derivative_x_data_list]
1993
+ if pre_derivative_y_data_list is not None:
1994
+ fig._pre_derivative_y_data_list = [np.array(a) for a in pre_derivative_y_data_list]
1995
+ if pre_derivative_ylabel is not None:
1996
+ fig._pre_derivative_ylabel = str(pre_derivative_ylabel)
1997
+ if derivative_order is not None:
1998
+ fig._derivative_order = int(derivative_order)
1999
+ derivative_reversed = sess.get('derivative_reversed')
2000
+ if derivative_reversed is not None:
2001
+ fig._derivative_reversed = bool(derivative_reversed)
1966
2002
  n_curves = len(x_loaded)
1967
2003
  for i in range(n_curves):
1968
- x_arr = np.array(x_loaded[i])
2004
+ # Ensure arrays are 1D and have matching shapes
2005
+ x_arr = np.asarray(x_loaded[i], dtype=float).flatten()
1969
2006
  off = offsets_saved[i] if i < len(offsets_saved) else 0.0
1970
2007
  if orig_loaded and i < len(orig_loaded):
1971
- base = np.array(orig_loaded[i])
2008
+ base = np.asarray(orig_loaded[i], dtype=float).flatten()
1972
2009
  else:
1973
2010
  # Fallback: derive baseline by subtracting offset from stored y (handles legacy sessions)
1974
- y_arr_full = np.array(y_loaded[i]) if i < len(y_loaded) else np.array([])
2011
+ y_arr_full = np.asarray(y_loaded[i], dtype=float).flatten() if i < len(y_loaded) else np.array([], dtype=float)
1975
2012
  base = y_arr_full - off
2013
+ # Ensure x and y have matching lengths
2014
+ if x_arr.size != base.size:
2015
+ print(f"Warning: Curve {i+1} has mismatched x/y lengths ({x_arr.size} vs {base.size}). Trimming to match.")
2016
+ min_len = min(x_arr.size, base.size)
2017
+ x_arr = x_arr[:min_len]
2018
+ base = base[:min_len]
1976
2019
  y_plot = base + off
1977
2020
  x_data_list.append(x_arr)
1978
2021
  orig_y.append(base)
@@ -2276,8 +2319,24 @@ def batplot_main() -> int:
2276
2319
  cif_hkl_label_map = {k: dict(v) for k,v in sess.get('cif_hkl_label_map', {}).items()}
2277
2320
  cif_numbering_enabled = True
2278
2321
  cif_extend_suspended = False
2279
- show_cif_hkl = sess.get('show_cif_hkl', False)
2280
- show_cif_titles = sess.get('show_cif_titles', True)
2322
+ # Restore CIF visibility flags - default to False for hkl (labels hidden by default)
2323
+ # and True for titles (shown by default)
2324
+ show_cif_hkl = bool(sess.get('show_cif_hkl', False))
2325
+ show_cif_titles = bool(sess.get('show_cif_titles', True))
2326
+
2327
+ # Store CIF state in __main__ module for interactive menu to access
2328
+ # This ensures CIF commands (z, hkl, j) are available in the menu
2329
+ try:
2330
+ _bp_module = sys.modules.get('__main__')
2331
+ if _bp_module is not None and cif_tick_series:
2332
+ setattr(_bp_module, 'cif_tick_series', list(cif_tick_series))
2333
+ setattr(_bp_module, 'cif_hkl_map', cif_hkl_map)
2334
+ setattr(_bp_module, 'cif_hkl_label_map', cif_hkl_label_map)
2335
+ setattr(_bp_module, 'show_cif_hkl', bool(show_cif_hkl))
2336
+ setattr(_bp_module, 'show_cif_titles', bool(show_cif_titles))
2337
+ setattr(_bp_module, 'cif_extend_suspended', False)
2338
+ except Exception:
2339
+ pass
2281
2340
  # Provide minimal stubs to satisfy interactive menu dependencies
2282
2341
  # Axis mode restoration informs downstream toggles (e.g., CIF conversions, crosshair availability)
2283
2342
  axis_mode_restored = sess.get('axis_mode', 'unknown')
@@ -2336,13 +2395,20 @@ def batplot_main() -> int:
2336
2395
  if not cif_tick_series:
2337
2396
  return
2338
2397
  try:
2339
- # Preserve both x and y-axis limits to prevent movement when toggling
2398
+ # Preserve current limits before drawing - use actual current limits
2399
+ # to prevent any movement when toggling
2340
2400
  prev_xlim = ax.get_xlim()
2341
2401
  prev_ylim = ax.get_ylim()
2342
- # Use preserved y-axis limits for calculations to prevent incremental movement
2343
- orig_ylim = prev_ylim
2344
- orig_yr = orig_ylim[1] - orig_ylim[0]
2345
- if orig_yr <= 0: orig_yr = 1.0
2402
+
2403
+ # Use current ylim as fixed reference to prevent incremental movement
2404
+ # This ensures that repeated 'z' commands don't cause drift
2405
+ # Store it only once on first call, then reuse
2406
+ if not hasattr(ax, '_cif_initial_ylim'):
2407
+ ax._cif_initial_ylim = tuple(prev_ylim)
2408
+ fixed_ylim = ax._cif_initial_ylim
2409
+ fixed_yr = fixed_ylim[1] - fixed_ylim[0]
2410
+ if fixed_yr <= 0: fixed_yr = 1.0
2411
+
2346
2412
  # Check visibility flag first
2347
2413
  show_titles_local = bool(show_cif_titles) # Use closure variable from outer scope
2348
2414
  # Also check figure attribute and module attribute as fallback
@@ -2356,38 +2422,53 @@ def batplot_main() -> int:
2356
2422
  show_titles_local = bool(getattr(_bp_module, 'show_cif_titles', show_titles_local))
2357
2423
  except Exception:
2358
2424
  pass
2359
- # Calculate base and spacing based on original y-axis limits
2425
+
2426
+ # Check hkl visibility - check __main__ module first (where interactive menu stores it)
2427
+ # then fall back to closure variable
2428
+ show_hkl_local = False
2429
+ try:
2430
+ _bp_module = sys.modules.get('__main__')
2431
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
2432
+ show_hkl_local = bool(getattr(_bp_module, 'show_cif_hkl', False))
2433
+ except Exception:
2434
+ pass
2435
+ # Fall back to closure variable if not found in module
2436
+ if not show_hkl_local:
2437
+ try:
2438
+ show_hkl_local = bool(show_cif_hkl)
2439
+ except Exception:
2440
+ pass
2441
+
2442
+ # Calculate base and spacing based on FIXED y-axis limits (not current)
2443
+ # This prevents incremental movement when toggling
2360
2444
  if saved_stack or len(y_data_list) > 1:
2361
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
2362
- base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
2445
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else fixed_ylim[0]
2446
+ base = global_min - 0.08*fixed_yr; spacing = 0.05*fixed_yr
2363
2447
  else:
2364
2448
  global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2365
- base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
2449
+ base = global_min - 0.06*fixed_yr; spacing = 0.04*fixed_yr
2450
+
2366
2451
  # Only adjust y-axis limits if titles are visible
2367
- needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
2452
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*fixed_yr
2453
+ if show_titles_local and needed_min < fixed_ylim[0]:
2454
+ # Expand y-axis only if needed, using fixed limits as reference
2455
+ ax.set_ylim(needed_min, fixed_ylim[1])
2456
+ else:
2457
+ # Restore to fixed limits if no expansion needed
2458
+ ax.set_ylim(fixed_ylim)
2459
+
2460
+ # Get current limits for drawing (after potential expansion)
2368
2461
  cur_ylim = ax.get_ylim()
2369
2462
  yr = cur_ylim[1] - cur_ylim[0]
2370
2463
  if yr <= 0: yr = 1.0
2371
- if show_titles_local and needed_min < orig_ylim[0]:
2372
- # Expand y-axis only if needed, using original limits as reference
2373
- ax.set_ylim(needed_min, orig_ylim[1])
2374
- cur_ylim = ax.get_ylim()
2375
- yr = cur_ylim[1] - cur_ylim[0]
2376
- if yr <= 0: yr = 1.0
2377
- # Recalculate base with new limits if we expanded
2378
- if saved_stack or len(y_data_list) > 1:
2379
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
2380
- base = global_min - 0.08*yr; spacing = 0.05*yr
2381
- else:
2382
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2383
- base = global_min - 0.06*yr; spacing = 0.04*yr
2464
+
2384
2465
  # Clear previous artifacts
2385
2466
  for art in getattr(ax, '_cif_tick_art', []):
2386
2467
  try: art.remove()
2387
2468
  except Exception: pass
2388
2469
  new_art = []
2389
- show_hkl_local = bool(show_cif_hkl)
2390
2470
  wl_any = _session_ensure_wavelength()
2471
+
2391
2472
  # Draw each series
2392
2473
  for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
2393
2474
  y_line = base - i*spacing
@@ -2401,12 +2482,18 @@ def batplot_main() -> int:
2401
2482
  xlow,xhigh = ax.get_xlim()
2402
2483
  domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
2403
2484
  # Build hkl label map (keys are Q values, not 2θ)
2404
- label_map = cif_hkl_label_map.get(fname, {}) if show_hkl_local else {}
2485
+ # Only use label_map if hkl labels are enabled
2486
+ label_map = {}
2487
+ if show_hkl_local:
2488
+ label_map = cif_hkl_label_map.get(fname, {})
2405
2489
  if show_hkl_local and len(domain_peaks) > 4000:
2406
2490
  show_hkl_local = False # safety
2491
+ label_map = {} # Clear label map if too many peaks
2407
2492
  for p in domain_peaks:
2493
+ # Use color from tuple (preserved from session)
2408
2494
  ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
2409
2495
  new_art.append(ln)
2496
+ # Only show hkl labels if explicitly enabled
2410
2497
  if show_hkl_local:
2411
2498
  # When axis is 2θ convert back to Q to look up hkl label
2412
2499
  if use_2th and (wl or wl_any):
@@ -2414,28 +2501,35 @@ def batplot_main() -> int:
2414
2501
  Qp = 4*np.pi*np.sin(theta)/(wl if wl is not None else wl_any)
2415
2502
  else:
2416
2503
  Qp = p
2417
- lbl = label_map.get(round(Qp,6))
2504
+ Qp_rounded = round(Qp, 6)
2505
+ lbl = label_map.get(Qp_rounded)
2418
2506
  if lbl:
2507
+ # Use same color as tick line
2419
2508
  t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
2420
2509
  new_art.append(t_hkl)
2421
- # Removed numbering prefix; keep one leading space for padding from axis
2422
2510
  # Only add title label if show_cif_titles is True
2423
2511
  if show_titles_local:
2424
2512
  label_text = f" {lab}"
2513
+ # Use color from tuple (preserved from session)
2425
2514
  txt = ax.text(prev_xlim[0], y_line+0.005*yr, label_text,
2426
2515
  ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',16))), color=color)
2427
2516
  new_art.append(txt)
2428
2517
  ax._cif_tick_art = new_art
2429
- # Restore both x and y-axis limits to prevent movement
2518
+ # Restore x-axis limits
2430
2519
  ax.set_xlim(prev_xlim)
2431
2520
  # Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
2521
+ # Use prev_ylim (current limits before drawing) to prevent any movement
2432
2522
  if not show_titles_local:
2433
2523
  # Titles hidden: always restore original limits
2434
2524
  ax.set_ylim(prev_ylim)
2435
2525
  elif needed_min >= prev_ylim[0]:
2436
2526
  # Titles shown but no expansion needed: restore original limits
2437
2527
  ax.set_ylim(prev_ylim)
2438
- # Otherwise, keep the expanded limits (already set above)
2528
+ else:
2529
+ # Expansion needed: use the minimum of needed_min and prev_ylim[0] to prevent incremental growth
2530
+ # This ensures that repeated toggles don't cause drift
2531
+ new_ymin = min(needed_min, prev_ylim[0])
2532
+ ax.set_ylim(new_ymin, prev_ylim[1])
2439
2533
  fig.canvas.draw_idle()
2440
2534
  except Exception:
2441
2535
  pass
@@ -2547,20 +2641,34 @@ def batplot_main() -> int:
2547
2641
  except Exception:
2548
2642
  pass
2549
2643
 
2644
+ # Prepare CIF globals for interactive menu (ensures CIF commands are available)
2645
+ cif_globals_dict = None
2646
+ if cif_tick_series:
2647
+ cif_globals_dict = {
2648
+ 'cif_tick_series': list(cif_tick_series),
2649
+ 'cif_hkl_map': cif_hkl_map,
2650
+ 'cif_hkl_label_map': cif_hkl_label_map,
2651
+ 'show_cif_hkl': bool(show_cif_hkl),
2652
+ 'show_cif_titles': bool(show_cif_titles),
2653
+ 'cif_extend_suspended': False,
2654
+ 'keep_canvas_fixed': True,
2655
+ }
2656
+
2550
2657
  interactive_menu(fig, ax, y_data_list, x_data_list, labels_list,
2551
2658
  orig_y, label_text_objects, delta, x_label, args,
2552
2659
  x_full_list, raw_y_full_list, offsets_list,
2553
- use_Q, use_r, use_E, use_k, use_rft)
2660
+ use_Q, use_r, use_E, use_k, use_rft,
2661
+ cif_globals=cif_globals_dict)
2554
2662
  plt.show()
2555
2663
  exit()
2556
2664
 
2557
2665
  # ---------------- Handle conversion ----------------
2558
2666
  if args.convert:
2559
- if args.wl is None:
2560
- print("Error: --wl is required for --convert")
2561
-
2667
+ if not args.files:
2668
+ print("Error: --convert requires file(s) to convert")
2562
2669
  exit(1)
2563
- convert_to_qye(args.convert, args.wl)
2670
+ from_param, to_param = args.convert
2671
+ convert_xrd_data(args.files, from_param, to_param)
2564
2672
  exit()
2565
2673
 
2566
2674
  # ---------------- Plotting ----------------
@@ -2947,6 +3055,19 @@ def batplot_main() -> int:
2947
3055
  # ---- Store full (converted) arrays BEFORE cropping ----
2948
3056
  x_full = x_plot.copy()
2949
3057
  y_full_raw = y.copy()
3058
+
3059
+ # ---- Calculate first derivative if requested ----
3060
+ if getattr(args, 'derivative_1d', False) or getattr(args, 'derivative_2d', False):
3061
+ # Calculate dy/dx using numpy gradient
3062
+ # numpy.gradient handles non-uniform spacing automatically
3063
+ if len(y_full_raw) > 1:
3064
+ dy_dx = np.gradient(y_full_raw, x_full)
3065
+ y_full_raw = dy_dx
3066
+ else:
3067
+ # Single point or empty - cannot calculate derivative
3068
+ print(f"Warning: Cannot calculate derivative for {fname}: insufficient data points")
3069
+ continue
3070
+
2950
3071
  raw_y_full_list.append(y_full_raw)
2951
3072
  x_full_list.append(x_full)
2952
3073
 
@@ -3180,13 +3301,20 @@ def batplot_main() -> int:
3180
3301
  def draw_cif_ticks():
3181
3302
  if not cif_tick_series:
3182
3303
  return
3183
- # Preserve both x and y-axis limits to prevent movement when toggling
3304
+ # Preserve current limits before drawing - use actual current limits
3305
+ # to prevent any movement when toggling
3184
3306
  prev_xlim = ax.get_xlim()
3185
3307
  prev_ylim = ax.get_ylim()
3186
- # Use preserved y-axis limits for calculations to prevent incremental movement
3187
- orig_ylim = prev_ylim
3188
- orig_yr = orig_ylim[1] - orig_ylim[0]
3189
- if orig_yr <= 0: orig_yr = 1.0
3308
+
3309
+ # Store initial limits as fixed reference point to prevent incremental movement
3310
+ # This ensures that repeated 'z' commands don't cause drift
3311
+ # Only set once on first call, then reuse to prevent drift
3312
+ if not hasattr(ax, '_cif_initial_ylim'):
3313
+ ax._cif_initial_ylim = tuple(prev_ylim)
3314
+ fixed_ylim = ax._cif_initial_ylim
3315
+ fixed_yr = fixed_ylim[1] - fixed_ylim[0]
3316
+ if fixed_yr <= 0: fixed_yr = 1.0
3317
+
3190
3318
  # Check visibility flag first to decide if we need to adjust y-axis
3191
3319
  show_titles = show_cif_titles # Use closure variable
3192
3320
  try:
@@ -3199,38 +3327,50 @@ def batplot_main() -> int:
3199
3327
  show_titles = bool(getattr(fig, '_bp_show_cif_titles', True))
3200
3328
  except Exception:
3201
3329
  pass
3202
- # Calculate base and spacing based on original y-axis limits
3330
+
3331
+ # Calculate base and spacing based on FIXED y-axis limits (not current)
3332
+ # This prevents incremental movement when toggling
3203
3333
  if args.stack or len(y_data_list) > 1:
3204
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
3205
- base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
3334
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else fixed_ylim[0]
3335
+ base = global_min - 0.08*fixed_yr; spacing = 0.05*fixed_yr
3206
3336
  else:
3207
3337
  global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3208
- base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
3338
+ base = global_min - 0.06*fixed_yr; spacing = 0.04*fixed_yr
3339
+
3209
3340
  # Only adjust y-axis limits if titles are visible
3210
- needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
3341
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*fixed_yr
3342
+ if show_titles and needed_min < fixed_ylim[0]:
3343
+ # Expand y-axis only if needed, using fixed limits as reference
3344
+ ax.set_ylim(needed_min, fixed_ylim[1])
3345
+ else:
3346
+ # Restore to fixed limits if no expansion needed
3347
+ ax.set_ylim(fixed_ylim)
3348
+
3349
+ # Get current limits for drawing (after potential expansion)
3211
3350
  cur_ylim = ax.get_ylim()
3212
3351
  yr = cur_ylim[1] - cur_ylim[0]
3213
3352
  if yr <= 0: yr = 1.0
3214
- if show_titles and needed_min < orig_ylim[0]:
3215
- # Expand y-axis only if needed, using original limits as reference
3216
- ax.set_ylim(needed_min, orig_ylim[1])
3217
- cur_ylim = ax.get_ylim()
3218
- yr = cur_ylim[1] - cur_ylim[0]
3219
- if yr <= 0: yr = 1.0
3220
- # Recalculate base with new limits if we expanded
3221
- if args.stack or len(y_data_list) > 1:
3222
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
3223
- base = global_min - 0.08*yr; spacing = 0.05*yr
3224
- else:
3225
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3226
- base = global_min - 0.06*yr; spacing = 0.04*yr
3227
3353
  # Clear previous
3228
3354
  for art in getattr(ax, '_cif_tick_art', []):
3229
3355
  try: art.remove()
3230
3356
  except Exception: pass
3231
3357
  new_art = []
3232
3358
  mixed_mode = (not cif_only) # cif_only variable defined earlier in script context
3233
- show_hkl = globals().get('show_cif_hkl', False)
3359
+ # Check hkl visibility - check __main__ module first (where interactive menu stores it)
3360
+ # then fall back to closure variable
3361
+ show_hkl = False
3362
+ try:
3363
+ _bp_module = sys.modules.get('__main__')
3364
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
3365
+ show_hkl = bool(getattr(_bp_module, 'show_cif_hkl', False))
3366
+ except Exception:
3367
+ pass
3368
+ # Fall back to closure variable if not found in module
3369
+ if not show_hkl:
3370
+ try:
3371
+ show_hkl = bool(globals().get('show_cif_hkl', False))
3372
+ except Exception:
3373
+ pass
3234
3374
  for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
3235
3375
  y_line = base - i*spacing
3236
3376
  if use_2th:
@@ -3252,14 +3392,12 @@ def batplot_main() -> int:
3252
3392
  ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
3253
3393
  new_art.append(txt)
3254
3394
  continue
3255
- # Build map for quick hkl lookup by Q
3256
- hkl_entries = cif_hkl_map.get(fname, [])
3257
- # dictionary keyed by Q value
3258
- hkl_by_q = {}
3259
- for qval,h,k,l in hkl_entries:
3260
- hkl_by_q.setdefault(qval, []).append((h,k,l))
3261
- label_map = cif_hkl_label_map.get(fname, {})
3395
+ # Build map for quick hkl lookup by Q (only if hkl labels are enabled)
3396
+ label_map = {}
3397
+ if show_hkl:
3398
+ label_map = cif_hkl_label_map.get(fname, {})
3262
3399
  # --- Optimized tick & hkl label drawing ---
3400
+ # Check if we should show hkl labels: need show_hkl, peaks, AND a non-empty label_map
3263
3401
  if show_hkl and peaksQ and label_map:
3264
3402
  # Guard against pathological large peak lists (can freeze UI)
3265
3403
  if len(peaksQ) > 4000 or len(domain_peaks) > 4000:
@@ -3282,7 +3420,8 @@ def batplot_main() -> int:
3282
3420
  Qp = 4*np.pi*np.sin(theta)/wl
3283
3421
  else:
3284
3422
  Qp = p
3285
- lbl = label_map.get(round(Qp,6))
3423
+ Qp_rounded = round(Qp, 6)
3424
+ lbl = label_map.get(Qp_rounded)
3286
3425
  if lbl:
3287
3426
  t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
3288
3427
  new_art.append(t_hkl)
@@ -3308,7 +3447,11 @@ def batplot_main() -> int:
3308
3447
  elif needed_min >= prev_ylim[0]:
3309
3448
  # Titles shown but no expansion needed: restore original limits
3310
3449
  ax.set_ylim(prev_ylim)
3311
- # Otherwise, keep the expanded limits (already set above)
3450
+ else:
3451
+ # Expansion needed: use the minimum of needed_min and prev_ylim[0] to prevent incremental growth
3452
+ # This ensures that repeated toggles don't cause drift
3453
+ new_ymin = min(needed_min, prev_ylim[0])
3454
+ ax.set_ylim(new_ymin, prev_ylim[1])
3312
3455
  # Store simplified metadata for hover: list of dicts with 'x','y','label'
3313
3456
  hover_meta = []
3314
3457
  show_hkl = globals().get('show_cif_hkl', False)
batplot/cli.py CHANGED
@@ -59,6 +59,20 @@ def main(argv: Optional[list] = None) -> int:
59
59
  >>> main()
60
60
  0
61
61
  """
62
+ # ====================================================================
63
+ # STEP 0: PYTHON VERSION CHECK
64
+ # ====================================================================
65
+ # Check if Python version is 3.13 (required for batplot)
66
+ # ====================================================================
67
+ if sys.version_info.major != 3 or sys.version_info.minor != 13:
68
+ current_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
69
+ print(f"\n⚠️ WARNING: Python version mismatch detected!")
70
+ print(f" batplot requires Python 3.13")
71
+ print(f" Currently running: Python {current_version}")
72
+ print(f"\n This may cause compatibility issues.")
73
+ print(f" Please install Python 3.13 and use it to run batplot.")
74
+ print(f" Continuing anyway, but expect potential issues...\n")
75
+
62
76
  # ====================================================================
63
77
  # STEP 1: VERSION CHECK (NON-BLOCKING)
64
78
  # ====================================================================