batplot 1.8.4__py3-none-any.whl → 1.8.11__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 = []
@@ -2603,7 +2763,17 @@ def batplot_main() -> int:
2603
2763
  any_cif = any(f.lower().endswith(".cif") for f in args.files)
2604
2764
  non_cif_count = sum(0 if f.lower().endswith('.cif') else 1 for f in args.files)
2605
2765
  cif_only = any_cif and non_cif_count == 0
2606
- any_lambda = any(":" in f for f in args.files) or args.wl is not None
2766
+ # Check for wavelength parameters (file:wl), but exclude Windows drive letters (C:\...)
2767
+ def has_wavelength_param(f):
2768
+ if ":" not in f:
2769
+ return False
2770
+ # Check if it's a Windows path (single letter followed by :\ or :/)
2771
+ if len(f) >= 2 and f[1] == ':' and len(f[0]) == 1 and f[0].isalpha():
2772
+ # This is a Windows drive letter, check after the drive path
2773
+ # Look for additional colons beyond the drive letter
2774
+ return ":" in f[2:]
2775
+ return True
2776
+ any_lambda = any(has_wavelength_param(f) for f in args.files) or args.wl is not None
2607
2777
 
2608
2778
  # Incompatibilities (no mixing of fundamentally different axis domains)
2609
2779
  if sum(bool(x) for x in (any_gr, any_nor, any_chik, any_chir, (any_qye or any_lambda or any_cif))) > 1:
@@ -2623,7 +2793,8 @@ def batplot_main() -> int:
2623
2793
  elif any_txt:
2624
2794
  # .txt is generic, require --xaxis
2625
2795
  if args.xaxis:
2626
- axis_mode = args.xaxis
2796
+ # Normalize case: 'q' or 'Q' → 'Q' (uppercase), everything else lowercase
2797
+ axis_mode = "Q" if args.xaxis.upper() == "Q" else args.xaxis.lower()
2627
2798
  else:
2628
2799
  raise ValueError("Unknown file type. Use: batplot file.txt --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2629
2800
  elif any_lambda or any_cif:
@@ -2634,7 +2805,8 @@ def batplot_main() -> int:
2634
2805
  # CIF files are in Q space
2635
2806
  axis_mode = "Q"
2636
2807
  elif args.xaxis:
2637
- axis_mode = args.xaxis
2808
+ # Normalize case: 'q' or 'Q' → 'Q' (uppercase), everything else lowercase
2809
+ axis_mode = "Q" if args.xaxis.upper() == "Q" else args.xaxis.lower()
2638
2810
  else:
2639
2811
  raise ValueError("Unknown file type. Use: batplot file.csv --xaxis [Q|2theta|r|k|energy|rft] or batplot -h for help.")
2640
2812
 
@@ -2734,8 +2906,16 @@ def batplot_main() -> int:
2734
2906
 
2735
2907
  # Use data_files instead of args.files for processing
2736
2908
  for idx_file, file_entry in enumerate(data_files):
2909
+ # Handle Windows paths (C:\...) vs wavelength parameters (file:wl)
2910
+ # On Windows, check if first part is a single letter (drive letter)
2737
2911
  parts = file_entry.split(":")
2738
- fname = parts[0]
2912
+ if len(parts) > 1 and len(parts[0]) == 1 and parts[0].isalpha():
2913
+ # Windows drive letter detected (e.g., "C" from "C:\path")
2914
+ # Rejoin the first two parts as the filename
2915
+ fname = parts[0] + ":" + parts[1]
2916
+ parts = [fname] + parts[2:] # Reconstruct parts with full Windows path
2917
+ else:
2918
+ fname = parts[0]
2739
2919
  # Parse wavelength parameters: file:wl1 or file:wl1:wl2 or file.cif:wl
2740
2920
  wavelength_file = None
2741
2921
  original_wavelength = None # First wavelength (for Q conversion)
@@ -2919,16 +3099,35 @@ def batplot_main() -> int:
2919
3099
  # Step 2: Convert Q back to 2theta using second wavelength
2920
3100
  # Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
2921
3101
  sin_theta = Q * conversion_wavelength / (4 * np.pi)
2922
- # Clamp to valid range [-1, 1]
2923
- sin_theta = np.clip(sin_theta, -1.0, 1.0)
3102
+ # Check for physically impossible Q values (would require sin(θ) > 1)
3103
+ valid_mask = np.abs(sin_theta) <= 1.0
3104
+ if not np.all(valid_mask):
3105
+ # Some Q values are too high for the target wavelength
3106
+ n_invalid = np.sum(~valid_mask)
3107
+ q_max_possible = 4 * np.pi / conversion_wavelength
3108
+ print(f"Warning: {n_invalid} data points exceed Q_max={q_max_possible:.2f} Å⁻¹ for λ={conversion_wavelength} Å")
3109
+ print(f" Truncating data to physically accessible range.")
3110
+ # Truncate to valid range instead of clipping (which creates artificial data)
3111
+ x = x[valid_mask]
3112
+ y = y[valid_mask]
3113
+ sin_theta = sin_theta[valid_mask]
2924
3114
  theta_new_rad = np.arcsin(sin_theta)
2925
3115
  x_plot = np.degrees(2 * theta_new_rad)
2926
3116
  elif use_2th and file_ext == ".qye" and wavelength_file:
2927
3117
  # Convert Q to 2theta for .qye files when wavelength is provided
2928
3118
  # Q = 4π sin(θ) / λ => sin(θ) = Qλ / (4π) => θ = arcsin(Qλ / (4π))
2929
3119
  sin_theta = x * wavelength_file / (4 * np.pi)
2930
- # Clamp to valid range [-1, 1]
2931
- sin_theta = np.clip(sin_theta, -1.0, 1.0)
3120
+ # Check for physically impossible Q values
3121
+ valid_mask = np.abs(sin_theta) <= 1.0
3122
+ if not np.all(valid_mask):
3123
+ n_invalid = np.sum(~valid_mask)
3124
+ q_max_possible = 4 * np.pi / wavelength_file
3125
+ print(f"Warning: {n_invalid} data points exceed Q_max={q_max_possible:.2f} Å⁻¹ for λ={wavelength_file} Å")
3126
+ print(f" Truncating data to physically accessible range.")
3127
+ # Truncate to valid range
3128
+ x = x[valid_mask]
3129
+ y = y[valid_mask]
3130
+ sin_theta = sin_theta[valid_mask]
2932
3131
  theta_rad = np.arcsin(sin_theta)
2933
3132
  x_plot = np.degrees(2 * theta_rad)
2934
3133
  elif use_Q and file_ext not in (".qye", ".gr", ".nor"):
@@ -2948,6 +3147,19 @@ def batplot_main() -> int:
2948
3147
  # ---- Store full (converted) arrays BEFORE cropping ----
2949
3148
  x_full = x_plot.copy()
2950
3149
  y_full_raw = y.copy()
3150
+
3151
+ # ---- Calculate first derivative if requested ----
3152
+ if getattr(args, 'derivative_1d', False) or getattr(args, 'derivative_2d', False):
3153
+ # Calculate dy/dx using numpy gradient
3154
+ # numpy.gradient handles non-uniform spacing automatically
3155
+ if len(y_full_raw) > 1:
3156
+ dy_dx = np.gradient(y_full_raw, x_full)
3157
+ y_full_raw = dy_dx
3158
+ else:
3159
+ # Single point or empty - cannot calculate derivative
3160
+ print(f"Warning: Cannot calculate derivative for {fname}: insufficient data points")
3161
+ continue
3162
+
2951
3163
  raw_y_full_list.append(y_full_raw)
2952
3164
  x_full_list.append(x_full)
2953
3165
 
@@ -3181,13 +3393,20 @@ def batplot_main() -> int:
3181
3393
  def draw_cif_ticks():
3182
3394
  if not cif_tick_series:
3183
3395
  return
3184
- # Preserve both x and y-axis limits to prevent movement when toggling
3396
+ # Preserve current limits before drawing - use actual current limits
3397
+ # to prevent any movement when toggling
3185
3398
  prev_xlim = ax.get_xlim()
3186
3399
  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
3400
+
3401
+ # Store initial limits as fixed reference point to prevent incremental movement
3402
+ # This ensures that repeated 'z' commands don't cause drift
3403
+ # Only set once on first call, then reuse to prevent drift
3404
+ if not hasattr(ax, '_cif_initial_ylim'):
3405
+ ax._cif_initial_ylim = tuple(prev_ylim)
3406
+ fixed_ylim = ax._cif_initial_ylim
3407
+ fixed_yr = fixed_ylim[1] - fixed_ylim[0]
3408
+ if fixed_yr <= 0: fixed_yr = 1.0
3409
+
3191
3410
  # Check visibility flag first to decide if we need to adjust y-axis
3192
3411
  show_titles = show_cif_titles # Use closure variable
3193
3412
  try:
@@ -3200,38 +3419,50 @@ def batplot_main() -> int:
3200
3419
  show_titles = bool(getattr(fig, '_bp_show_cif_titles', True))
3201
3420
  except Exception:
3202
3421
  pass
3203
- # Calculate base and spacing based on original y-axis limits
3422
+
3423
+ # Calculate base and spacing based on FIXED y-axis limits (not current)
3424
+ # This prevents incremental movement when toggling
3204
3425
  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
3426
+ global_min = min(float(a.min()) for a in y_data_list if len(a)) if y_data_list else fixed_ylim[0]
3427
+ base = global_min - 0.08*fixed_yr; spacing = 0.05*fixed_yr
3207
3428
  else:
3208
3429
  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
3430
+ base = global_min - 0.06*fixed_yr; spacing = 0.04*fixed_yr
3431
+
3210
3432
  # Only adjust y-axis limits if titles are visible
3211
- needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*orig_yr
3433
+ needed_min = base - (len(cif_tick_series)-1)*spacing - 0.04*fixed_yr
3434
+ if show_titles and needed_min < fixed_ylim[0]:
3435
+ # Expand y-axis only if needed, using fixed limits as reference
3436
+ ax.set_ylim(needed_min, fixed_ylim[1])
3437
+ else:
3438
+ # Restore to fixed limits if no expansion needed
3439
+ ax.set_ylim(fixed_ylim)
3440
+
3441
+ # Get current limits for drawing (after potential expansion)
3212
3442
  cur_ylim = ax.get_ylim()
3213
3443
  yr = cur_ylim[1] - cur_ylim[0]
3214
3444
  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
3445
  # Clear previous
3229
3446
  for art in getattr(ax, '_cif_tick_art', []):
3230
3447
  try: art.remove()
3231
3448
  except Exception: pass
3232
3449
  new_art = []
3233
3450
  mixed_mode = (not cif_only) # cif_only variable defined earlier in script context
3234
- show_hkl = globals().get('show_cif_hkl', False)
3451
+ # Check hkl visibility - check __main__ module first (where interactive menu stores it)
3452
+ # then fall back to closure variable
3453
+ show_hkl = False
3454
+ try:
3455
+ _bp_module = sys.modules.get('__main__')
3456
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
3457
+ show_hkl = bool(getattr(_bp_module, 'show_cif_hkl', False))
3458
+ except Exception:
3459
+ pass
3460
+ # Fall back to closure variable if not found in module
3461
+ if not show_hkl:
3462
+ try:
3463
+ show_hkl = bool(globals().get('show_cif_hkl', False))
3464
+ except Exception:
3465
+ pass
3235
3466
  for i,(lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series):
3236
3467
  y_line = base - i*spacing
3237
3468
  if use_2th:
@@ -3253,14 +3484,12 @@ def batplot_main() -> int:
3253
3484
  ha='left', va='bottom', fontsize=max(8,int(0.55*plt.rcParams.get('font.size',12))), color=color)
3254
3485
  new_art.append(txt)
3255
3486
  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, {})
3487
+ # Build map for quick hkl lookup by Q (only if hkl labels are enabled)
3488
+ label_map = {}
3489
+ if show_hkl:
3490
+ label_map = cif_hkl_label_map.get(fname, {})
3263
3491
  # --- Optimized tick & hkl label drawing ---
3492
+ # Check if we should show hkl labels: need show_hkl, peaks, AND a non-empty label_map
3264
3493
  if show_hkl and peaksQ and label_map:
3265
3494
  # Guard against pathological large peak lists (can freeze UI)
3266
3495
  if len(peaksQ) > 4000 or len(domain_peaks) > 4000:
@@ -3283,7 +3512,8 @@ def batplot_main() -> int:
3283
3512
  Qp = 4*np.pi*np.sin(theta)/wl
3284
3513
  else:
3285
3514
  Qp = p
3286
- lbl = label_map.get(round(Qp,6))
3515
+ Qp_rounded = round(Qp, 6)
3516
+ lbl = label_map.get(Qp_rounded)
3287
3517
  if lbl:
3288
3518
  t_hkl = ax.text(p, y_line+0.022*yr, lbl, ha='center', va='bottom', fontsize=7, rotation=90, color=color)
3289
3519
  new_art.append(t_hkl)
@@ -3309,7 +3539,11 @@ def batplot_main() -> int:
3309
3539
  elif needed_min >= prev_ylim[0]:
3310
3540
  # Titles shown but no expansion needed: restore original limits
3311
3541
  ax.set_ylim(prev_ylim)
3312
- # Otherwise, keep the expanded limits (already set above)
3542
+ else:
3543
+ # Expansion needed: use the minimum of needed_min and prev_ylim[0] to prevent incremental growth
3544
+ # This ensures that repeated toggles don't cause drift
3545
+ new_ymin = min(needed_min, prev_ylim[0])
3546
+ ax.set_ylim(new_ymin, prev_ylim[1])
3313
3547
  # Store simplified metadata for hover: list of dicts with 'x','y','label'
3314
3548
  hover_meta = []
3315
3549
  show_hkl = globals().get('show_cif_hkl', False)
@@ -3390,19 +3624,36 @@ def batplot_main() -> int:
3390
3624
  ax._cif_hover_cid = cid
3391
3625
 
3392
3626
  if cif_tick_series:
3393
- # Auto-assign distinct colors if all are default 'k'
3627
+ # Auto-assign distinct colors for CIF tick series.
3628
+ # For multiple CIF series:
3629
+ # - If <= 10 files, use 'tab10' but in a re-ordered sequence to
3630
+ # maximize visual separation between adjacent colors.
3631
+ # - If > 10 files, use 'viridis' with evenly spaced samples.
3632
+ #
3633
+ # This overrides any previous per-series color so that the requested
3634
+ # colormap behavior is always enforced.
3394
3635
  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)
3636
+ try:
3637
+ n_cif = len(cif_tick_series)
3638
+ if n_cif <= 10:
3639
+ tab10 = plt.get_cmap('tab10').colors
3640
+ # Reorder indices for more distinct neighboring colors
3641
+ order = [0, 3, 6, 1, 4, 7, 2, 5, 8, 9]
3399
3642
  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
3643
+ for i, (lab, fname, peaksQ, wl, qmax_sim, col) in enumerate(cif_tick_series):
3644
+ idx = order[i] if i < len(order) else i % len(tab10)
3645
+ color = tab10[idx]
3646
+ new_series.append((lab, fname, peaksQ, wl, qmax_sim, color))
3647
+ else:
3648
+ cmap = plt.get_cmap('viridis')
3649
+ positions = np.linspace(0.0, 1.0, n_cif)
3650
+ new_series = []
3651
+ for (pos, (lab, fname, peaksQ, wl, qmax_sim, col)) in zip(positions, cif_tick_series):
3652
+ color = cmap(pos)
3653
+ new_series.append((lab, fname, peaksQ, wl, qmax_sim, color))
3654
+ cif_tick_series[:] = new_series
3655
+ except Exception:
3656
+ pass
3406
3657
  if use_2th:
3407
3658
  _ensure_wavelength_for_2theta()
3408
3659
  draw_cif_ticks()