batplot 1.8.4__py3-none-any.whl → 1.8.6__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.
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,
@@ -422,7 +422,10 @@ def batplot_main() -> int:
422
422
  # Ensure font and canvas settings match GC/dQdV
423
423
  _plt.rcParams.update({
424
424
  'font.family': 'sans-serif',
425
- 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
425
+ # Prefer DejaVu Sans first because it has good Unicode
426
+ # coverage (including subscript/superscript digits), then
427
+ # fall back to other common sans-serif fonts.
428
+ 'font.sans-serif': ['DejaVu Sans', 'Arial', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
426
429
  'mathtext.fontset': 'dejavusans',
427
430
  'font.size': 16
428
431
  })
@@ -616,8 +619,10 @@ def batplot_main() -> int:
616
619
  # Set global default font
617
620
  plt.rcParams.update({
618
621
  'font.family': 'sans-serif',
619
- 'font.sans-serif': ['Arial', 'DejaVu Sans', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
620
- 'mathtext.fontset': 'dejavusans', # keeps math consistent with Arial-like sans
622
+ # Use DejaVu Sans first to ensure good Unicode coverage (subscripts,
623
+ # superscripts, Greek, etc.), then fall back to other common fonts.
624
+ 'font.sans-serif': ['DejaVu Sans', 'Arial', 'Helvetica', 'STIXGeneral', 'Liberation Sans', 'Arial Unicode MS'],
625
+ 'mathtext.fontset': 'dejavusans', # keeps math consistent with sans-serif
621
626
  'font.size': 16
622
627
  })
623
628
 
@@ -1165,11 +1170,9 @@ def batplot_main() -> int:
1165
1170
  print(f"Skipped {file_basename}: unsupported format (must be .csv, .xlsx, or .mpt)")
1166
1171
  continue
1167
1172
 
1168
- # Assign colors: distinct hue for each file
1173
+ # Assign colors: distinct hue per file
1169
1174
  capacity_color = capacity_colors[file_idx % len(capacity_colors)]
1170
1175
  efficiency_color = efficiency_colors[file_idx % len(efficiency_colors)]
1171
- # Generate discharge color using the same function as interactive menu
1172
- discharge_color = _generate_similar_color(capacity_color)
1173
1176
 
1174
1177
  file_data.append({
1175
1178
  'filename': file_basename,
@@ -1179,7 +1182,6 @@ def batplot_main() -> int:
1179
1182
  'cap_discharge': cap_discharge,
1180
1183
  'eff': eff,
1181
1184
  'color': capacity_color,
1182
- 'discharge_color': discharge_color,
1183
1185
  'eff_color': efficiency_color,
1184
1186
  'visible': True
1185
1187
  })
@@ -1207,7 +1209,7 @@ def batplot_main() -> int:
1207
1209
  cap_charge = file_info['cap_charge']
1208
1210
  cap_discharge = file_info['cap_discharge']
1209
1211
  eff = file_info['eff']
1210
- color = file_info['color'] # Warm color for capacity
1212
+ color = file_info['color'] # Base color for capacity (both charge/discharge)
1211
1213
  eff_color = file_info['eff_color'] # Cold color for efficiency
1212
1214
  label = file_info['filename']
1213
1215
 
@@ -1221,16 +1223,30 @@ def batplot_main() -> int:
1221
1223
  label_dch = f'{label} (Dch)'
1222
1224
  label_eff = f'{label} (Eff)'
1223
1225
 
1224
- # Use stored discharge color if available, otherwise generate it
1225
- if 'discharge_color' in file_info and file_info['discharge_color'] is not None:
1226
- discharge_color = file_info['discharge_color']
1227
- else:
1228
- discharge_color = _generate_similar_color(color)
1229
-
1230
- sc_charge = ax.scatter(cyc_nums, cap_charge, color=color, label=label_chg,
1231
- s=32, zorder=3, alpha=0.8, marker='o')
1232
- sc_discharge = ax.scatter(cyc_nums, cap_discharge, color=discharge_color, label=label_dch,
1233
- s=32, zorder=3, alpha=0.8, marker='s')
1226
+ # Capacity curves: same color, different fill style
1227
+ # - Charge: filled square
1228
+ # - Discharge: hollow square (edge only)
1229
+ sc_charge = ax.scatter(
1230
+ cyc_nums,
1231
+ cap_charge,
1232
+ label=label_chg,
1233
+ s=32,
1234
+ zorder=3,
1235
+ alpha=0.8,
1236
+ marker='s',
1237
+ color=color,
1238
+ )
1239
+ sc_discharge = ax.scatter(
1240
+ cyc_nums,
1241
+ cap_discharge,
1242
+ label=label_dch,
1243
+ s=32,
1244
+ zorder=3,
1245
+ alpha=0.8,
1246
+ marker='s',
1247
+ facecolor='none',
1248
+ edgecolor=color,
1249
+ )
1234
1250
  sc_eff = ax2.scatter(cyc_nums, eff, color=eff_color, marker='^', label=label_eff,
1235
1251
  s=40, alpha=0.7, zorder=3)
1236
1252
 
@@ -1247,27 +1263,25 @@ def batplot_main() -> int:
1247
1263
  h1, l1 = ax.get_legend_handles_labels()
1248
1264
  h2, l2 = ax2.get_legend_handles_labels()
1249
1265
  combined_handles = h1 + h2
1250
- leg = ax.legend(
1251
- combined_handles, l1 + l2,
1252
- loc='best',
1253
- frameon=False,
1254
- handlelength=1.0,
1255
- handletextpad=0.35,
1256
- labelspacing=0.25,
1257
- borderaxespad=0.5,
1258
- borderpad=0.3,
1259
- columnspacing=0.6,
1260
- labelcolor='linecolor',
1261
- )
1262
- if leg is not None:
1263
- try:
1264
- leg.set_frame_on(False)
1265
- leg.set_edgecolor('none')
1266
- leg.set_facecolor('none')
1267
- except Exception:
1268
- pass
1269
- except Exception:
1270
- pass
1266
+ combined_labels = l1 + l2
1267
+ if combined_handles:
1268
+ # Don't use labelcolor='linecolor' for scatter plots with hollow markers
1269
+ # as it causes issues with color extraction
1270
+ leg = ax.legend(
1271
+ combined_handles, combined_labels,
1272
+ loc='best',
1273
+ frameon=False,
1274
+ handlelength=1.0,
1275
+ handletextpad=0.35,
1276
+ labelspacing=0.25,
1277
+ borderaxespad=0.5,
1278
+ borderpad=0.3,
1279
+ columnspacing=0.6,
1280
+ )
1281
+ except Exception as e:
1282
+ print(f"Warning: Could not create CPC legend: {e}")
1283
+ import traceback
1284
+ traceback.print_exc()
1271
1285
 
1272
1286
  # Adjust layout to ensure top and bottom labels/titles are visible
1273
1287
  fig.subplots_adjust(left=0.12, right=0.88, top=0.88, bottom=0.15)
@@ -1842,6 +1856,11 @@ def batplot_main() -> int:
1842
1856
  _plt.show(block=False)
1843
1857
  except Exception:
1844
1858
  pass
1859
+ # Seed last-session path so 'os' overwrite command is available immediately
1860
+ try:
1861
+ fig._last_session_save_path = os.path.abspath(sess_path)
1862
+ except Exception:
1863
+ pass
1845
1864
  try:
1846
1865
  source_list = list(getattr(fig, '_bp_source_paths', []) or [])
1847
1866
  sess_abs = os.path.abspath(sess_path)
@@ -1877,6 +1896,11 @@ def batplot_main() -> int:
1877
1896
  _plt.show(block=False)
1878
1897
  except Exception:
1879
1898
  pass
1899
+ # Seed last-session path so 'os' overwrite command is available immediately
1900
+ try:
1901
+ fig2._last_session_save_path = os.path.abspath(sess_path)
1902
+ except Exception:
1903
+ pass
1880
1904
  try:
1881
1905
  if operando_ec_interactive_menu is not None:
1882
1906
  operando_ec_interactive_menu(fig2, ax2, im2, cbar2, ec_ax2)
@@ -1905,6 +1929,11 @@ def batplot_main() -> int:
1905
1929
  _plt.show(block=False)
1906
1930
  except Exception:
1907
1931
  pass
1932
+ # Seed last-session path so 'os' overwrite command is available immediately
1933
+ try:
1934
+ fig_c._last_session_save_path = os.path.abspath(sess_path)
1935
+ except Exception:
1936
+ pass
1908
1937
  try:
1909
1938
  if cpc_interactive_menu is not None:
1910
1939
  cpc_interactive_menu(fig_c, ax_c, ax2_c, sc_c, sc_d, sc_e, file_data=file_data)
@@ -1964,16 +1993,58 @@ def batplot_main() -> int:
1964
1993
  y_loaded = sess.get('y_data', []) # stored plotted (baseline+offset) values
1965
1994
  orig_loaded = sess.get('orig_y', []) # stored baseline (normalized/raw w/out offsets)
1966
1995
  offsets_saved = sess.get('offsets', [])
1996
+ # Restore processed data (for smooth/reduce operations)
1997
+ original_x_data_list = sess.get('original_x_data_list')
1998
+ original_y_data_list = sess.get('original_y_data_list')
1999
+ smooth_settings = sess.get('smooth_settings')
2000
+ if original_x_data_list is not None:
2001
+ fig._original_x_data_list = [np.array(a) for a in original_x_data_list]
2002
+ if original_y_data_list is not None:
2003
+ fig._original_y_data_list = [np.array(a) for a in original_y_data_list]
2004
+ full_processed_x_data_list = sess.get('full_processed_x_data_list')
2005
+ full_processed_y_data_list = sess.get('full_processed_y_data_list')
2006
+ if full_processed_x_data_list is not None:
2007
+ fig._full_processed_x_data_list = [np.array(a) for a in full_processed_x_data_list]
2008
+ if full_processed_y_data_list is not None:
2009
+ fig._full_processed_y_data_list = [np.array(a) for a in full_processed_y_data_list]
2010
+ if smooth_settings is not None:
2011
+ fig._smooth_settings = dict(smooth_settings)
2012
+ last_smooth_settings = sess.get('last_smooth_settings')
2013
+ if last_smooth_settings is not None:
2014
+ fig._last_smooth_settings = dict(last_smooth_settings)
2015
+ # Restore derivative data (for derivative operations)
2016
+ pre_derivative_x_data_list = sess.get('pre_derivative_x_data_list')
2017
+ pre_derivative_y_data_list = sess.get('pre_derivative_y_data_list')
2018
+ pre_derivative_ylabel = sess.get('pre_derivative_ylabel')
2019
+ derivative_order = sess.get('derivative_order')
2020
+ if pre_derivative_x_data_list is not None:
2021
+ fig._pre_derivative_x_data_list = [np.array(a) for a in pre_derivative_x_data_list]
2022
+ if pre_derivative_y_data_list is not None:
2023
+ fig._pre_derivative_y_data_list = [np.array(a) for a in pre_derivative_y_data_list]
2024
+ if pre_derivative_ylabel is not None:
2025
+ fig._pre_derivative_ylabel = str(pre_derivative_ylabel)
2026
+ if derivative_order is not None:
2027
+ fig._derivative_order = int(derivative_order)
2028
+ derivative_reversed = sess.get('derivative_reversed')
2029
+ if derivative_reversed is not None:
2030
+ fig._derivative_reversed = bool(derivative_reversed)
1967
2031
  n_curves = len(x_loaded)
1968
2032
  for i in range(n_curves):
1969
- x_arr = np.array(x_loaded[i])
2033
+ # Ensure arrays are 1D and have matching shapes
2034
+ x_arr = np.asarray(x_loaded[i], dtype=float).flatten()
1970
2035
  off = offsets_saved[i] if i < len(offsets_saved) else 0.0
1971
2036
  if orig_loaded and i < len(orig_loaded):
1972
- base = np.array(orig_loaded[i])
2037
+ base = np.asarray(orig_loaded[i], dtype=float).flatten()
1973
2038
  else:
1974
2039
  # Fallback: derive baseline by subtracting offset from stored y (handles legacy sessions)
1975
- y_arr_full = np.array(y_loaded[i]) if i < len(y_loaded) else np.array([])
2040
+ y_arr_full = np.asarray(y_loaded[i], dtype=float).flatten() if i < len(y_loaded) else np.array([], dtype=float)
1976
2041
  base = y_arr_full - off
2042
+ # Ensure x and y have matching lengths
2043
+ if x_arr.size != base.size:
2044
+ print(f"Warning: Curve {i+1} has mismatched x/y lengths ({x_arr.size} vs {base.size}). Trimming to match.")
2045
+ min_len = min(x_arr.size, base.size)
2046
+ x_arr = x_arr[:min_len]
2047
+ base = base[:min_len]
1977
2048
  y_plot = base + off
1978
2049
  x_data_list.append(x_arr)
1979
2050
  orig_y.append(base)
@@ -2165,6 +2236,14 @@ def batplot_main() -> int:
2165
2236
  fig._tick_lengths['minor'] = minor_len
2166
2237
  except Exception:
2167
2238
  pass
2239
+ # Tick direction restore (t submenu)
2240
+ try:
2241
+ tick_direction = sess.get('tick_direction', 'out')
2242
+ if tick_direction:
2243
+ setattr(fig, '_tick_direction', tick_direction)
2244
+ ax.tick_params(axis='both', which='both', direction=tick_direction)
2245
+ except Exception:
2246
+ pass
2168
2247
 
2169
2248
  # Restore WASD state (spine, ticks, labels, title visibility for all 4 sides)
2170
2249
  try:
@@ -2277,8 +2356,24 @@ def batplot_main() -> int:
2277
2356
  cif_hkl_label_map = {k: dict(v) for k,v in sess.get('cif_hkl_label_map', {}).items()}
2278
2357
  cif_numbering_enabled = True
2279
2358
  cif_extend_suspended = False
2280
- show_cif_hkl = sess.get('show_cif_hkl', False)
2281
- show_cif_titles = sess.get('show_cif_titles', True)
2359
+ # Restore CIF visibility flags - default to False for hkl (labels hidden by default)
2360
+ # and True for titles (shown by default)
2361
+ show_cif_hkl = bool(sess.get('show_cif_hkl', False))
2362
+ show_cif_titles = bool(sess.get('show_cif_titles', True))
2363
+
2364
+ # Store CIF state in __main__ module for interactive menu to access
2365
+ # This ensures CIF commands (z, hkl, j) are available in the menu
2366
+ try:
2367
+ _bp_module = sys.modules.get('__main__')
2368
+ if _bp_module is not None and cif_tick_series:
2369
+ setattr(_bp_module, 'cif_tick_series', list(cif_tick_series))
2370
+ setattr(_bp_module, 'cif_hkl_map', cif_hkl_map)
2371
+ setattr(_bp_module, 'cif_hkl_label_map', cif_hkl_label_map)
2372
+ setattr(_bp_module, 'show_cif_hkl', bool(show_cif_hkl))
2373
+ setattr(_bp_module, 'show_cif_titles', bool(show_cif_titles))
2374
+ setattr(_bp_module, 'cif_extend_suspended', False)
2375
+ except Exception:
2376
+ pass
2282
2377
  # Provide minimal stubs to satisfy interactive menu dependencies
2283
2378
  # Axis mode restoration informs downstream toggles (e.g., CIF conversions, crosshair availability)
2284
2379
  axis_mode_restored = sess.get('axis_mode', 'unknown')
@@ -2337,13 +2432,20 @@ def batplot_main() -> int:
2337
2432
  if not cif_tick_series:
2338
2433
  return
2339
2434
  try:
2340
- # Preserve both x and y-axis limits to prevent movement when toggling
2435
+ # Preserve current limits before drawing - use actual current limits
2436
+ # to prevent any movement when toggling
2341
2437
  prev_xlim = ax.get_xlim()
2342
2438
  prev_ylim = ax.get_ylim()
2343
- # Use preserved y-axis limits for calculations to prevent incremental movement
2344
- orig_ylim = prev_ylim
2345
- orig_yr = orig_ylim[1] - orig_ylim[0]
2346
- if orig_yr <= 0: orig_yr = 1.0
2439
+
2440
+ # Use current ylim as fixed reference to prevent incremental movement
2441
+ # This ensures that repeated 'z' commands don't cause drift
2442
+ # Store it only once on first call, then reuse
2443
+ if not hasattr(ax, '_cif_initial_ylim'):
2444
+ ax._cif_initial_ylim = tuple(prev_ylim)
2445
+ fixed_ylim = ax._cif_initial_ylim
2446
+ fixed_yr = fixed_ylim[1] - fixed_ylim[0]
2447
+ if fixed_yr <= 0: fixed_yr = 1.0
2448
+
2347
2449
  # Check visibility flag first
2348
2450
  show_titles_local = bool(show_cif_titles) # Use closure variable from outer scope
2349
2451
  # Also check figure attribute and module attribute as fallback
@@ -2357,38 +2459,53 @@ def batplot_main() -> int:
2357
2459
  show_titles_local = bool(getattr(_bp_module, 'show_cif_titles', show_titles_local))
2358
2460
  except Exception:
2359
2461
  pass
2360
- # Calculate base and spacing based on original y-axis limits
2462
+
2463
+ # Check hkl visibility - check __main__ module first (where interactive menu stores it)
2464
+ # then fall back to closure variable
2465
+ show_hkl_local = False
2466
+ try:
2467
+ _bp_module = sys.modules.get('__main__')
2468
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
2469
+ show_hkl_local = bool(getattr(_bp_module, 'show_cif_hkl', False))
2470
+ except Exception:
2471
+ pass
2472
+ # Fall back to closure variable if not found in module
2473
+ if not show_hkl_local:
2474
+ try:
2475
+ show_hkl_local = bool(show_cif_hkl)
2476
+ except Exception:
2477
+ pass
2478
+
2479
+ # Calculate base and spacing based on FIXED y-axis limits (not current)
2480
+ # This prevents incremental movement when toggling
2361
2481
  if saved_stack or len(y_data_list) > 1:
2362
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
2363
- base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
2482
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else fixed_ylim[0]
2483
+ base = global_min - 0.08*fixed_yr; spacing = 0.05*fixed_yr
2364
2484
  else:
2365
2485
  global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2366
- base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
2486
+ base = global_min - 0.06*fixed_yr; spacing = 0.04*fixed_yr
2487
+
2367
2488
  # Only adjust y-axis limits if titles are visible
2368
- needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
2489
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*fixed_yr
2490
+ if show_titles_local and needed_min < fixed_ylim[0]:
2491
+ # Expand y-axis only if needed, using fixed limits as reference
2492
+ ax.set_ylim(needed_min, fixed_ylim[1])
2493
+ else:
2494
+ # Restore to fixed limits if no expansion needed
2495
+ ax.set_ylim(fixed_ylim)
2496
+
2497
+ # Get current limits for drawing (after potential expansion)
2369
2498
  cur_ylim = ax.get_ylim()
2370
2499
  yr = cur_ylim[1] - cur_ylim[0]
2371
2500
  if yr <= 0: yr = 1.0
2372
- if show_titles_local and needed_min < orig_ylim[0]:
2373
- # Expand y-axis only if needed, using original limits as reference
2374
- ax.set_ylim(needed_min, orig_ylim[1])
2375
- cur_ylim = ax.get_ylim()
2376
- yr = cur_ylim[1] - cur_ylim[0]
2377
- if yr <= 0: yr = 1.0
2378
- # Recalculate base with new limits if we expanded
2379
- if saved_stack or len(y_data_list) > 1:
2380
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
2381
- base = global_min - 0.08*yr; spacing = 0.05*yr
2382
- else:
2383
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
2384
- base = global_min - 0.06*yr; spacing = 0.04*yr
2501
+
2385
2502
  # Clear previous artifacts
2386
2503
  for art in getattr(ax, '_cif_tick_art', []):
2387
2504
  try: art.remove()
2388
2505
  except Exception: pass
2389
2506
  new_art = []
2390
- show_hkl_local = bool(show_cif_hkl)
2391
2507
  wl_any = _session_ensure_wavelength()
2508
+
2392
2509
  # Draw each series
2393
2510
  for i,(lab,fname,peaksQ,wl,qmax_sim,color) in enumerate(cif_tick_series):
2394
2511
  y_line = base - i*spacing
@@ -2402,12 +2519,18 @@ def batplot_main() -> int:
2402
2519
  xlow,xhigh = ax.get_xlim()
2403
2520
  domain_peaks = [p for p in domain_peaks if xlow <= p <= xhigh]
2404
2521
  # Build hkl label map (keys are Q values, not 2θ)
2405
- label_map = cif_hkl_label_map.get(fname, {}) if show_hkl_local else {}
2522
+ # Only use label_map if hkl labels are enabled
2523
+ label_map = {}
2524
+ if show_hkl_local:
2525
+ label_map = cif_hkl_label_map.get(fname, {})
2406
2526
  if show_hkl_local and len(domain_peaks) > 4000:
2407
2527
  show_hkl_local = False # safety
2528
+ label_map = {} # Clear label map if too many peaks
2408
2529
  for p in domain_peaks:
2530
+ # Use color from tuple (preserved from session)
2409
2531
  ln, = ax.plot([p,p],[y_line, y_line+0.02*yr], color=color, lw=1.0, alpha=0.9, zorder=3)
2410
2532
  new_art.append(ln)
2533
+ # Only show hkl labels if explicitly enabled
2411
2534
  if show_hkl_local:
2412
2535
  # When axis is 2θ convert back to Q to look up hkl label
2413
2536
  if use_2th and (wl or wl_any):
@@ -2415,28 +2538,35 @@ def batplot_main() -> int:
2415
2538
  Qp = 4*np.pi*np.sin(theta)/(wl if wl is not None else wl_any)
2416
2539
  else:
2417
2540
  Qp = p
2418
- lbl = label_map.get(round(Qp,6))
2541
+ Qp_rounded = round(Qp, 6)
2542
+ lbl = label_map.get(Qp_rounded)
2419
2543
  if lbl:
2544
+ # Use same color as tick line
2420
2545
  t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
2421
2546
  new_art.append(t_hkl)
2422
- # Removed numbering prefix; keep one leading space for padding from axis
2423
2547
  # Only add title label if show_cif_titles is True
2424
2548
  if show_titles_local:
2425
2549
  label_text = f" {lab}"
2550
+ # Use color from tuple (preserved from session)
2426
2551
  txt = ax.text(prev_xlim[0], y_line+0.005*yr, label_text,
2427
2552
  ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',16))), color=color)
2428
2553
  new_art.append(txt)
2429
2554
  ax._cif_tick_art = new_art
2430
- # Restore both x and y-axis limits to prevent movement
2555
+ # Restore x-axis limits
2431
2556
  ax.set_xlim(prev_xlim)
2432
2557
  # Restore y-axis: if titles are hidden, always restore; if titles are shown, only restore if we didn't need to expand
2558
+ # Use prev_ylim (current limits before drawing) to prevent any movement
2433
2559
  if not show_titles_local:
2434
2560
  # Titles hidden: always restore original limits
2435
2561
  ax.set_ylim(prev_ylim)
2436
2562
  elif needed_min >= prev_ylim[0]:
2437
2563
  # Titles shown but no expansion needed: restore original limits
2438
2564
  ax.set_ylim(prev_ylim)
2439
- # Otherwise, keep the expanded limits (already set above)
2565
+ else:
2566
+ # Expansion needed: use the minimum of needed_min and prev_ylim[0] to prevent incremental growth
2567
+ # This ensures that repeated toggles don't cause drift
2568
+ new_ymin = min(needed_min, prev_ylim[0])
2569
+ ax.set_ylim(new_ymin, prev_ylim[1])
2440
2570
  fig.canvas.draw_idle()
2441
2571
  except Exception:
2442
2572
  pass
@@ -2548,28 +2678,58 @@ def batplot_main() -> int:
2548
2678
  except Exception:
2549
2679
  pass
2550
2680
 
2681
+ # Prepare CIF globals for interactive menu (ensures CIF commands are available)
2682
+ cif_globals_dict = None
2683
+ if cif_tick_series:
2684
+ cif_globals_dict = {
2685
+ 'cif_tick_series': list(cif_tick_series),
2686
+ 'cif_hkl_map': cif_hkl_map,
2687
+ 'cif_hkl_label_map': cif_hkl_label_map,
2688
+ 'show_cif_hkl': bool(show_cif_hkl),
2689
+ 'show_cif_titles': bool(show_cif_titles),
2690
+ 'cif_extend_suspended': False,
2691
+ 'keep_canvas_fixed': True,
2692
+ }
2693
+
2551
2694
  interactive_menu(fig, ax, y_data_list, x_data_list, labels_list,
2552
2695
  orig_y, label_text_objects, delta, x_label, args,
2553
2696
  x_full_list, raw_y_full_list, offsets_list,
2554
- use_Q, use_r, use_E, use_k, use_rft)
2697
+ use_Q, use_r, use_E, use_k, use_rft,
2698
+ cif_globals=cif_globals_dict)
2555
2699
  plt.show()
2556
2700
  exit()
2557
2701
 
2558
2702
  # ---------------- Handle conversion ----------------
2559
2703
  if args.convert:
2560
- if args.wl is None:
2561
- print("Error: --wl is required for --convert")
2562
-
2704
+ if not args.files:
2705
+ print("Error: --convert requires file(s) to convert")
2563
2706
  exit(1)
2564
- convert_to_qye(args.convert, args.wl)
2707
+ from_param, to_param = args.convert
2708
+ convert_xrd_data(args.files, from_param, to_param)
2565
2709
  exit()
2566
2710
 
2567
2711
  # ---------------- Plotting ----------------
2568
2712
  offset = 0.0
2569
2713
  direction = -1 if args.stack else 1 # stack downward
2570
2714
  if args.interactive:
2715
+ # Interactive: keep a reasonably compact default size so the window
2716
+ # fits well on most screens; margins are handled by the menu logic.
2571
2717
  plt.ion()
2572
- fig, ax = plt.subplots(figsize=(8, 6))
2718
+ figsize = (8, 6)
2719
+ else:
2720
+ # Non-interactive (no --i): use a slightly larger canvas so that labels,
2721
+ # titles, legends, and CIF ticks are not clipped even with long filenames.
2722
+ # The size (9, 6.4) keeps a similar aspect ratio but with a bit more room
2723
+ # than the interactive default while still fitting comfortably on screen.
2724
+ figsize = (9.5, 6.4)
2725
+ fig, ax = plt.subplots(figsize=figsize)
2726
+
2727
+ # Set consistent margins for all modes.
2728
+ # This prevents labels/titles from being cut off at the edges.
2729
+ try:
2730
+ fig.subplots_adjust(left=0.125, right=0.9, top=0.88, bottom=0.11)
2731
+ except Exception:
2732
+ pass
2573
2733
 
2574
2734
  y_data_list = []
2575
2735
  x_data_list = []
@@ -2623,7 +2783,8 @@ def batplot_main() -> int:
2623
2783
  elif any_txt:
2624
2784
  # .txt is generic, require --xaxis
2625
2785
  if args.xaxis:
2626
- axis_mode = args.xaxis
2786
+ # Normalize case: 'q' or 'Q' → 'Q' (uppercase), everything else lowercase
2787
+ axis_mode = "Q" if args.xaxis.upper() == "Q" else args.xaxis.lower()
2627
2788
  else:
2628
2789
  raise ValueError("Unknown file type. Use: batplot file.txt --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2629
2790
  elif any_lambda or any_cif:
@@ -2634,7 +2795,8 @@ def batplot_main() -> int:
2634
2795
  # CIF files are in Q space
2635
2796
  axis_mode = "Q"
2636
2797
  elif args.xaxis:
2637
- axis_mode = args.xaxis
2798
+ # Normalize case: 'q' or 'Q' → 'Q' (uppercase), everything else lowercase
2799
+ axis_mode = "Q" if args.xaxis.upper() == "Q" else args.xaxis.lower()
2638
2800
  else:
2639
2801
  raise ValueError("Unknown file type. Use: batplot file.csv --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2640
2802
 
@@ -2948,6 +3110,19 @@ def batplot_main() -> int:
2948
3110
  # ---- Store full (converted) arrays BEFORE cropping ----
2949
3111
  x_full = x_plot.copy()
2950
3112
  y_full_raw = y.copy()
3113
+
3114
+ # ---- Calculate first derivative if requested ----
3115
+ if getattr(args, 'derivative_1d', False) or getattr(args, 'derivative_2d', False):
3116
+ # Calculate dy/dx using numpy gradient
3117
+ # numpy.gradient handles non-uniform spacing automatically
3118
+ if len(y_full_raw) > 1:
3119
+ dy_dx = np.gradient(y_full_raw, x_full)
3120
+ y_full_raw = dy_dx
3121
+ else:
3122
+ # Single point or empty - cannot calculate derivative
3123
+ print(f"Warning: Cannot calculate derivative for {fname}: insufficient data points")
3124
+ continue
3125
+
2951
3126
  raw_y_full_list.append(y_full_raw)
2952
3127
  x_full_list.append(x_full)
2953
3128
 
@@ -3181,13 +3356,20 @@ def batplot_main() -> int:
3181
3356
  def draw_cif_ticks():
3182
3357
  if not cif_tick_series:
3183
3358
  return
3184
- # Preserve both x and y-axis limits to prevent movement when toggling
3359
+ # Preserve current limits before drawing - use actual current limits
3360
+ # to prevent any movement when toggling
3185
3361
  prev_xlim = ax.get_xlim()
3186
3362
  prev_ylim = ax.get_ylim()
3187
- # Use preserved y-axis limits for calculations to prevent incremental movement
3188
- orig_ylim = prev_ylim
3189
- orig_yr = orig_ylim[1] - orig_ylim[0]
3190
- if orig_yr <= 0: orig_yr = 1.0
3363
+
3364
+ # Store initial limits as fixed reference point to prevent incremental movement
3365
+ # This ensures that repeated 'z' commands don't cause drift
3366
+ # Only set once on first call, then reuse to prevent drift
3367
+ if not hasattr(ax, '_cif_initial_ylim'):
3368
+ ax._cif_initial_ylim = tuple(prev_ylim)
3369
+ fixed_ylim = ax._cif_initial_ylim
3370
+ fixed_yr = fixed_ylim[1] - fixed_ylim[0]
3371
+ if fixed_yr <= 0: fixed_yr = 1.0
3372
+
3191
3373
  # Check visibility flag first to decide if we need to adjust y-axis
3192
3374
  show_titles = show_cif_titles # Use closure variable
3193
3375
  try:
@@ -3200,38 +3382,50 @@ def batplot_main() -> int:
3200
3382
  show_titles = bool(getattr(fig, '_bp_show_cif_titles', True))
3201
3383
  except Exception:
3202
3384
  pass
3203
- # Calculate base and spacing based on original y-axis limits
3385
+
3386
+ # Calculate base and spacing based on FIXED y-axis limits (not current)
3387
+ # This prevents incremental movement when toggling
3204
3388
  if args.stack or len(y_data_list) > 1:
3205
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else orig_ylim[0]
3206
- base = global_min - 0.08*orig_yr; spacing = 0.05*orig_yr
3389
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else fixed_ylim[0]
3390
+ base = global_min - 0.08*fixed_yr; spacing = 0.05*fixed_yr
3207
3391
  else:
3208
3392
  global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3209
- base = global_min - 0.06*orig_yr; spacing = 0.04*orig_yr
3393
+ base = global_min - 0.06*fixed_yr; spacing = 0.04*fixed_yr
3394
+
3210
3395
  # Only adjust y-axis limits if titles are visible
3211
- needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
3396
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*fixed_yr
3397
+ if show_titles and needed_min < fixed_ylim[0]:
3398
+ # Expand y-axis only if needed, using fixed limits as reference
3399
+ ax.set_ylim(needed_min, fixed_ylim[1])
3400
+ else:
3401
+ # Restore to fixed limits if no expansion needed
3402
+ ax.set_ylim(fixed_ylim)
3403
+
3404
+ # Get current limits for drawing (after potential expansion)
3212
3405
  cur_ylim = ax.get_ylim()
3213
3406
  yr = cur_ylim[1] - cur_ylim[0]
3214
3407
  if yr <= 0: yr = 1.0
3215
- if show_titles and needed_min < orig_ylim[0]:
3216
- # Expand y-axis only if needed, using original limits as reference
3217
- ax.set_ylim(needed_min, orig_ylim[1])
3218
- cur_ylim = ax.get_ylim()
3219
- yr = cur_ylim[1] - cur_ylim[0]
3220
- if yr <= 0: yr = 1.0
3221
- # Recalculate base with new limits if we expanded
3222
- if args.stack or len(y_data_list) > 1:
3223
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else cur_ylim[0]
3224
- base = global_min - 0.08*yr; spacing = 0.05*yr
3225
- else:
3226
- global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else 0.0
3227
- base = global_min - 0.06*yr; spacing = 0.04*yr
3228
3408
  # Clear previous
3229
3409
  for art in getattr(ax, '_cif_tick_art', []):
3230
3410
  try: art.remove()
3231
3411
  except Exception: pass
3232
3412
  new_art = []
3233
3413
  mixed_mode = (not cif_only) # cif_only variable defined earlier in script context
3234
- show_hkl = globals().get('show_cif_hkl', False)
3414
+ # Check hkl visibility - check __main__ module first (where interactive menu stores it)
3415
+ # then fall back to closure variable
3416
+ show_hkl = False
3417
+ try:
3418
+ _bp_module = sys.modules.get('__main__')
3419
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
3420
+ show_hkl = bool(getattr(_bp_module, 'show_cif_hkl', False))
3421
+ except Exception:
3422
+ pass
3423
+ # Fall back to closure variable if not found in module
3424
+ if not show_hkl:
3425
+ try:
3426
+ show_hkl = bool(globals().get('show_cif_hkl', False))
3427
+ except Exception:
3428
+ pass
3235
3429
  for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
3236
3430
  y_line = base - i*spacing
3237
3431
  if use_2th:
@@ -3253,14 +3447,12 @@ def batplot_main() -> int:
3253
3447
  ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
3254
3448
  new_art.append(txt)
3255
3449
  continue
3256
- # Build map for quick hkl lookup by Q
3257
- hkl_entries = cif_hkl_map.get(fname, [])
3258
- # dictionary keyed by Q value
3259
- hkl_by_q = {}
3260
- for qval,h,k,l in hkl_entries:
3261
- hkl_by_q.setdefault(qval, []).append((h,k,l))
3262
- label_map = cif_hkl_label_map.get(fname, {})
3450
+ # Build map for quick hkl lookup by Q (only if hkl labels are enabled)
3451
+ label_map = {}
3452
+ if show_hkl:
3453
+ label_map = cif_hkl_label_map.get(fname, {})
3263
3454
  # --- Optimized tick & hkl label drawing ---
3455
+ # Check if we should show hkl labels: need show_hkl, peaks, AND a non-empty label_map
3264
3456
  if show_hkl and peaksQ and label_map:
3265
3457
  # Guard against pathological large peak lists (can freeze UI)
3266
3458
  if len(peaksQ) > 4000 or len(domain_peaks) > 4000:
@@ -3283,7 +3475,8 @@ def batplot_main() -> int:
3283
3475
  Qp = 4*np.pi*np.sin(theta)/wl
3284
3476
  else:
3285
3477
  Qp = p
3286
- lbl = label_map.get(round(Qp,6))
3478
+ Qp_rounded = round(Qp, 6)
3479
+ lbl = label_map.get(Qp_rounded)
3287
3480
  if lbl:
3288
3481
  t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
3289
3482
  new_art.append(t_hkl)
@@ -3309,7 +3502,11 @@ def batplot_main() -> int:
3309
3502
  elif needed_min >= prev_ylim[0]:
3310
3503
  # Titles shown but no expansion needed: restore original limits
3311
3504
  ax.set_ylim(prev_ylim)
3312
- # Otherwise, keep the expanded limits (already set above)
3505
+ else:
3506
+ # Expansion needed: use the minimum of needed_min and prev_ylim[0] to prevent incremental growth
3507
+ # This ensures that repeated toggles don't cause drift
3508
+ new_ymin = min(needed_min, prev_ylim[0])
3509
+ ax.set_ylim(new_ymin, prev_ylim[1])
3313
3510
  # Store simplified metadata for hover: list of dicts with 'x','y','label'
3314
3511
  hover_meta = []
3315
3512
  show_hkl = globals().get('show_cif_hkl', False)
@@ -3390,19 +3587,36 @@ def batplot_main() -> int:
3390
3587
  ax._cif_hover_cid = cid
3391
3588
 
3392
3589
  if cif_tick_series:
3393
- # Auto-assign distinct colors if all are default 'k'
3590
+ # Auto-assign distinct colors for CIF tick series.
3591
+ # For multiple CIF series:
3592
+ # - If <= 10 files, use 'tab10' but in a re-ordered sequence to
3593
+ # maximize visual separation between adjacent colors.
3594
+ # - If > 10 files, use 'viridis' with evenly spaced samples.
3595
+ #
3596
+ # This overrides any previous per-series color so that the requested
3597
+ # colormap behavior is always enforced.
3394
3598
  if len(cif_tick_series) > 1:
3395
- if all(c[-1] == 'k' for c in cif_tick_series):
3396
- try:
3397
- cmap_name = 'tab10' if len(cif_tick_series) <= 10 else 'hsv'
3398
- cmap = plt.get_cmap(cmap_name)
3599
+ try:
3600
+ n_cif = len(cif_tick_series)
3601
+ if n_cif <= 10:
3602
+ tab10 = plt.get_cmap('tab10').colors
3603
+ # Reorder indices for more distinct neighboring colors
3604
+ order = [0, 3, 6, 1, 4, 7, 2, 5, 8, 9]
3399
3605
  new_series = []
3400
- for i,(lab,fname,peaksQ,wl,qmax_sim,col) in enumerate(cif_tick_series):
3401
- color = cmap(i / max(1,(len(cif_tick_series)-1)))
3402
- new_series.append((lab,fname,peaksQ,wl,qmax_sim,color))
3403
- cif_tick_series[:] = new_series
3404
- except Exception:
3405
- pass
3606
+ for i, (lab, fname, peaksQ, wl, qmax_sim, col) in enumerate(cif_tick_series):
3607
+ idx = order[i] if i < len(order) else i % len(tab10)
3608
+ color = tab10[idx]
3609
+ new_series.append((lab, fname, peaksQ, wl, qmax_sim, color))
3610
+ else:
3611
+ cmap = plt.get_cmap('viridis')
3612
+ positions = np.linspace(0.0, 1.0, n_cif)
3613
+ new_series = []
3614
+ for (pos, (lab, fname, peaksQ, wl, qmax_sim, col)) in zip(positions, cif_tick_series):
3615
+ color = cmap(pos)
3616
+ new_series.append((lab, fname, peaksQ, wl, qmax_sim, color))
3617
+ cif_tick_series[:] = new_series
3618
+ except Exception:
3619
+ pass
3406
3620
  if use_2th:
3407
3621
  _ensure_wavelength_for_2theta()
3408
3622
  draw_cif_ticks()