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/interactive.py CHANGED
@@ -57,6 +57,7 @@ from .color_utils import (
57
57
  ensure_colormap,
58
58
  _CUSTOM_CMAPS,
59
59
  )
60
+ from .config import load_config, save_config
60
61
 
61
62
 
62
63
  class _FilterIMKWarning:
@@ -253,16 +254,28 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
253
254
  has_cif = bool(getattr(_bp, 'cif_tick_series', None))
254
255
  except Exception:
255
256
  pass
256
- col1 = ["c: colors", "f: font", "l: line", "t: toggle axes", "g: size", "h: legend"]
257
+ col1 = ["c: colors", "f: font", "l: line", "t: toggle axes", "g: size", "h: legend", "sm: smooth"]
257
258
  if has_cif:
258
259
  col1.append("z: hkl")
259
260
  col1.append("j: CIF titles")
260
- col2 = ["a: rearrange", "d: offset", "r: rename", "x: change X", "y: change Y"]
261
+ col2 = ["a: rearrange", "o: offset", "r: rename", "x: change X", "y: change Y", "d: derivative"]
261
262
  col3 = ["v: find peaks", "n: crosshair", "p: print(export) style/geom", "i: import style/geom", "e: export figure", "s: save project", "b: undo", "q: quit"]
263
+
264
+ # Conditional overwrite shortcuts under (Options)
265
+ last_session = getattr(fig, "_last_session_save_path", None)
266
+ last_style = getattr(fig, "_last_style_export_path", None)
267
+ last_figure = getattr(fig, "_last_figure_export_path", None)
268
+ if last_session:
269
+ col3.append("os: overwrite session")
270
+ if last_style:
271
+ col3.append("ops: overwrite style")
272
+ col3.append("opsg: overwrite style+geom")
273
+ if last_figure:
274
+ col3.append("oe: overwrite figure")
262
275
 
263
276
  # Hide offset/y-range in stack mode
264
277
  if args.stack:
265
- col2 = [item for item in col2 if not item.startswith("d:") and not item.startswith("y:")]
278
+ col2 = [item for item in col2 if not item.startswith("o:") and not item.startswith("y:")]
266
279
 
267
280
  if not is_diffraction:
268
281
  col3 = [item for item in col3 if not item.startswith("n:")]
@@ -884,7 +897,17 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
884
897
  # NEW: style / diagnostics printer (clean version)
885
898
  def print_style_info():
886
899
  cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
887
- show_hkl = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else None
900
+ # Read show_cif_hkl from __main__ module (where it's stored when toggled)
901
+ show_hkl = None
902
+ try:
903
+ _bp_module = sys.modules.get('__main__')
904
+ if _bp_module is not None and hasattr(_bp_module, 'show_cif_hkl'):
905
+ show_hkl = bool(getattr(_bp_module, 'show_cif_hkl', False))
906
+ except Exception:
907
+ pass
908
+ # Fall back to _bp object if not in __main__
909
+ if show_hkl is None and _bp is not None:
910
+ show_hkl = bool(getattr(_bp, 'show_cif_hkl', False)) if hasattr(_bp, 'show_cif_hkl') else None
888
911
  return _bp_print_style_info(
889
912
  fig, ax,
890
913
  y_data_list, labels,
@@ -898,7 +921,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
898
921
  )
899
922
 
900
923
  # NEW: export current style to .bpcfg
901
- def export_style_config(filename, base_path=None, overwrite_path=None):
924
+ def export_style_config(filename, base_path=None, overwrite_path=None, force_kind=None):
902
925
  cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
903
926
  show_titles = bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True
904
927
  from .style import export_style_config as _export_style_config
@@ -917,6 +940,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
917
940
  base_path,
918
941
  show_cif_titles=show_titles,
919
942
  overwrite_path=overwrite_path,
943
+ force_kind=force_kind,
920
944
  )
921
945
 
922
946
  # NEW: apply imported style config (restricted application)
@@ -1083,6 +1107,342 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1083
1107
  # history management:
1084
1108
  state_history = []
1085
1109
 
1110
+ # ====================================================================
1111
+ # SMOOTHING AND REDUCE ROWS HELPER FUNCTIONS
1112
+ # ====================================================================
1113
+
1114
+ def _savgol_kernel(window: int, poly: int) -> np.ndarray:
1115
+ """Return Savitzky–Golay smoothing kernel of given window/poly."""
1116
+ half = window // 2
1117
+ x = np.arange(-half, half + 1, dtype=float)
1118
+ A = np.vander(x, poly + 1, increasing=True)
1119
+ ATA = A.T @ A
1120
+ ATA_inv = np.linalg.pinv(ATA)
1121
+ target = np.zeros(poly + 1, dtype=float)
1122
+ target[0] = 1.0 # evaluate polynomial at x=0
1123
+ coeffs = target @ ATA_inv @ A.T
1124
+ return coeffs
1125
+
1126
+ def _savgol_smooth(y: np.ndarray, window: int = 9, poly: int = 3) -> np.ndarray:
1127
+ """Apply Savitzky–Golay smoothing (defaults from DiffCapAnalyzer) to data."""
1128
+ n = y.size
1129
+ if n < 3:
1130
+ return y
1131
+ if window > n:
1132
+ window = n if n % 2 == 1 else n - 1
1133
+ if window < 3:
1134
+ return y
1135
+ if window % 2 == 0:
1136
+ window -= 1
1137
+ if window < 3:
1138
+ return y
1139
+ if poly >= window:
1140
+ poly = window - 1
1141
+ coeffs = _savgol_kernel(window, poly)
1142
+ half = window // 2
1143
+ padded = np.pad(y, (half, half), mode='edge')
1144
+ smoothed = np.convolve(padded, coeffs[::-1], mode='valid')
1145
+ return smoothed
1146
+
1147
+ def _fft_smooth(y: np.ndarray, points: int = 5, cutoff: float = 0.1) -> np.ndarray:
1148
+ """Apply FFT filter smoothing to data."""
1149
+ n = y.size
1150
+ if n < 3:
1151
+ return y
1152
+ # FFT
1153
+ fft_vals = np.fft.rfft(y)
1154
+ freq = np.fft.rfftfreq(n)
1155
+ # Low-pass filter: zero out frequencies above cutoff
1156
+ mask = freq <= cutoff
1157
+ fft_vals[~mask] = 0
1158
+ # Inverse FFT
1159
+ smoothed = np.fft.irfft(fft_vals, n)
1160
+ return smoothed
1161
+
1162
+ def _adjacent_average_smooth(y: np.ndarray, points: int = 5) -> np.ndarray:
1163
+ """Apply Adjacent-Averaging smoothing to data."""
1164
+ n = y.size
1165
+ if n < points:
1166
+ return y
1167
+ if points < 2:
1168
+ return y
1169
+ # Use convolution for moving average
1170
+ kernel = np.ones(points) / points
1171
+ # Pad edges
1172
+ padded = np.pad(y, (points//2, points//2), mode='edge')
1173
+ smoothed = np.convolve(padded, kernel, mode='valid')
1174
+ return smoothed
1175
+
1176
+ def _get_last_reduce_rows_settings(method: str) -> dict:
1177
+ """Get last reduce rows settings from config file.
1178
+
1179
+ Args:
1180
+ method: Method name ('delete_skip', 'delete_missing', 'merge')
1181
+
1182
+ Returns:
1183
+ Dictionary with last settings for the method, or empty dict if none
1184
+ """
1185
+ config = load_config()
1186
+ last_settings = config.get('last_reduce_rows_settings', {})
1187
+ return last_settings.get(method, {})
1188
+
1189
+ def _save_last_reduce_rows_settings(method: str, settings: dict) -> None:
1190
+ """Save last reduce rows settings to config file.
1191
+
1192
+ Args:
1193
+ method: Method name ('delete_skip', 'delete_missing', 'merge')
1194
+ settings: Dictionary with settings to save
1195
+ """
1196
+ config = load_config()
1197
+ if 'last_reduce_rows_settings' not in config:
1198
+ config['last_reduce_rows_settings'] = {}
1199
+ config['last_reduce_rows_settings'][method] = settings
1200
+ save_config(config)
1201
+
1202
+ def _get_last_smooth_settings_from_config() -> dict:
1203
+ """Get last smooth settings from config file (persistent across sessions).
1204
+
1205
+ Returns:
1206
+ Dictionary with last smooth settings, or empty dict if none
1207
+ """
1208
+ config = load_config()
1209
+ return config.get('last_smooth_settings', {})
1210
+
1211
+ def _save_last_smooth_settings_to_config(settings: dict) -> None:
1212
+ """Save last smooth settings to config file (persistent across sessions).
1213
+
1214
+ Args:
1215
+ settings: Dictionary with smooth settings to save
1216
+ """
1217
+ config = load_config()
1218
+ config['last_smooth_settings'] = settings
1219
+ save_config(config)
1220
+
1221
+ def _ensure_original_data():
1222
+ """Ensure original data is stored for all curves."""
1223
+ if not hasattr(fig, '_original_x_data_list'):
1224
+ fig._original_x_data_list = [np.array(a, copy=True) for a in x_data_list]
1225
+ fig._original_y_data_list = [np.array(a, copy=True) for a in y_data_list]
1226
+
1227
+ def _update_full_processed_data():
1228
+ """Update the full processed data (after all processing steps, before any X-range filtering)."""
1229
+ # This stores the complete processed data (reduce + smooth + derivative) for X-range filtering
1230
+ fig._full_processed_x_data_list = [np.array(a, copy=True) for a in x_data_list]
1231
+ fig._full_processed_y_data_list = [np.array(a, copy=True) for a in y_data_list]
1232
+
1233
+ def _reset_to_original():
1234
+ """Reset all curves to original data."""
1235
+ if not hasattr(fig, '_original_x_data_list'):
1236
+ return (False, 0, 0)
1237
+ reset_count = 0
1238
+ total_points = 0
1239
+ for i in range(min(len(fig._original_x_data_list), len(ax.lines))):
1240
+ try:
1241
+ orig_x = fig._original_x_data_list[i]
1242
+ orig_y = fig._original_y_data_list[i]
1243
+ # Restore offsets
1244
+ if i < len(offsets_list):
1245
+ orig_y_with_offset = orig_y + offsets_list[i]
1246
+ else:
1247
+ orig_y_with_offset = orig_y.copy()
1248
+ ax.lines[i].set_data(orig_x, orig_y_with_offset)
1249
+ x_data_list[i] = orig_x.copy()
1250
+ y_data_list[i] = orig_y_with_offset.copy()
1251
+ reset_count += 1
1252
+ total_points += len(orig_x)
1253
+ except Exception:
1254
+ pass
1255
+ # Clear processing settings
1256
+ if hasattr(fig, '_smooth_settings'):
1257
+ delattr(fig, '_smooth_settings')
1258
+ return (reset_count > 0, reset_count, total_points)
1259
+
1260
+ def _apply_data_changes():
1261
+ """Update plot and data lists after data modification."""
1262
+ for i in range(min(len(ax.lines), len(x_data_list), len(y_data_list))):
1263
+ try:
1264
+ ax.lines[i].set_data(x_data_list[i], y_data_list[i])
1265
+ except Exception:
1266
+ pass
1267
+ try:
1268
+ fig.canvas.draw_idle()
1269
+ except Exception:
1270
+ pass
1271
+
1272
+ def _calculate_derivative(x: np.ndarray, y: np.ndarray, order: int = 1) -> np.ndarray:
1273
+ """Calculate 1st or 2nd derivative using numpy gradient.
1274
+
1275
+ Args:
1276
+ x: X values
1277
+ y: Y values
1278
+ order: 1 for first derivative (dy/dx), 2 for second derivative (d²y/dx²)
1279
+
1280
+ Returns:
1281
+ Derivative array (same length as input)
1282
+ """
1283
+ if len(y) < 2:
1284
+ return y.copy()
1285
+ # Calculate dy/dx
1286
+ dy_dx = np.gradient(y, x)
1287
+ if order == 1:
1288
+ return dy_dx
1289
+ elif order == 2:
1290
+ # Calculate d²y/dx² = d(dy/dx)/dx
1291
+ if len(dy_dx) < 2:
1292
+ return np.zeros_like(y)
1293
+ d2y_dx2 = np.gradient(dy_dx, x)
1294
+ return d2y_dx2
1295
+ else:
1296
+ return y.copy()
1297
+
1298
+ def _calculate_reversed_derivative(x, y, order):
1299
+ """Calculate reversed 1st or 2nd derivative (dx/dy or d²x/dy²).
1300
+
1301
+ Args:
1302
+ x: X values
1303
+ y: Y values
1304
+ order: 1 for first reversed derivative (dx/dy), 2 for second reversed derivative (d²x/dy²)
1305
+
1306
+ Returns:
1307
+ Reversed derivative array (same length as input)
1308
+ """
1309
+ if len(y) < 2:
1310
+ return y.copy()
1311
+ # First calculate dy/dx
1312
+ dy_dx = np.gradient(y, x)
1313
+ # Avoid division by zero - replace zeros with small epsilon
1314
+ epsilon = 1e-10
1315
+ dy_dx_safe = np.where(np.abs(dy_dx) < epsilon, np.sign(dy_dx) * epsilon, dy_dx)
1316
+ # Calculate dx/dy = 1 / (dy/dx)
1317
+ dx_dy = 1.0 / dy_dx_safe
1318
+ if order == 1:
1319
+ return dx_dy
1320
+ elif order == 2:
1321
+ # Calculate d²x/dy² = d(dx/dy)/dy
1322
+ # d(dx/dy)/dy = d(1/(dy/dx))/dy = -1/(dy/dx)² * d²y/dx²
1323
+ if len(dx_dy) < 2:
1324
+ return np.zeros_like(y)
1325
+ # Calculate d²y/dx² first
1326
+ d2y_dx2 = np.gradient(dy_dx, x)
1327
+ # d²x/dy² = -d²y/dx² / (dy/dx)³
1328
+ d2x_dy2 = -d2y_dx2 / (dy_dx_safe ** 3)
1329
+ return d2x_dy2
1330
+ else:
1331
+ return y.copy()
1332
+
1333
+ def _update_ylabel_for_derivative(order: int, current_label: str = None, is_reversed: bool = False) -> str:
1334
+ """Generate appropriate y-axis label for derivative.
1335
+
1336
+ Args:
1337
+ order: 1 for first derivative, 2 for second derivative
1338
+ current_label: Current y-axis label (optional)
1339
+ is_reversed: True for reversed derivative (dx/dy), False for normal (dy/dx)
1340
+
1341
+ Returns:
1342
+ New y-axis label string
1343
+ """
1344
+ if current_label is None:
1345
+ current_label = ax.get_ylabel() or "Y"
1346
+
1347
+ # Try to detect common patterns and update accordingly
1348
+ current_lower = current_label.lower()
1349
+
1350
+ if is_reversed:
1351
+ # Reversed derivative: dx/dy or d²x/dy²
1352
+ y_label = current_label if current_label and current_label != "Y" else (ax.get_ylabel() or "Y")
1353
+ if order == 1:
1354
+ # First reversed derivative: dx/dy
1355
+ if x_label:
1356
+ return f"d({x_label})/d({y_label})"
1357
+ else:
1358
+ return f"dx/d({y_label})"
1359
+ else: # order == 2
1360
+ # Second reversed derivative: d²x/dy²
1361
+ if x_label:
1362
+ return f"d²({x_label})/d({y_label})²"
1363
+ else:
1364
+ return f"d²x/d({y_label})²"
1365
+
1366
+ # Normal derivative: dy/dx or d²y/dx²
1367
+ if order == 1:
1368
+ # First derivative: dy/dx or dY/dX
1369
+ if "/" in current_label:
1370
+ # If already has derivative notation, try to increment
1371
+ if "d²" in current_label or "d2" in current_lower:
1372
+ # Change from 2nd to 1st (shouldn't normally happen, but handle it)
1373
+ new_label = current_label.replace("d²", "d").replace("d2", "d")
1374
+ return new_label
1375
+ elif "d" in current_label.lower() and "/" in current_label:
1376
+ # Already has derivative, keep as is but update order if needed
1377
+ return current_label
1378
+ # Add d/dx prefix or suffix
1379
+ if x_label:
1380
+ if any(op in current_label for op in ["/", "(", "["]):
1381
+ # Complex label, prepend d/dx
1382
+ return f"d({current_label})/d({x_label})"
1383
+ else:
1384
+ # Simple label, use d/dx notation
1385
+ return f"d({current_label})/d({x_label})"
1386
+ else:
1387
+ return f"d({current_label})/dx"
1388
+ else: # order == 2
1389
+ # Second derivative: d²y/dx² or d2Y/dX2
1390
+ if "/" in current_label:
1391
+ if "d²" in current_label or "d2" in current_lower:
1392
+ # Already 2nd derivative, keep as is
1393
+ return current_label
1394
+ elif "d" in current_label.lower() and "/" in current_label:
1395
+ # First derivative, convert to second
1396
+ new_label = current_label.replace("d(", "d²(").replace("d2(", "d²(").replace("d/", "d²/").replace("/d(", "²/d(")
1397
+ return new_label
1398
+ # Add d²/dx² prefix
1399
+ if x_label:
1400
+ if any(op in current_label for op in ["/", "(", "["]):
1401
+ return f"d²({current_label})/d({x_label})²"
1402
+ else:
1403
+ return f"d²({current_label})/d({x_label})²"
1404
+ else:
1405
+ return f"d²({current_label})/dx²"
1406
+
1407
+ return current_label
1408
+
1409
+ def _ensure_pre_derivative_data():
1410
+ """Ensure pre-derivative data is stored for reset."""
1411
+ if not hasattr(fig, '_pre_derivative_x_data_list'):
1412
+ fig._pre_derivative_x_data_list = [np.array(a, copy=True) for a in x_data_list]
1413
+ fig._pre_derivative_y_data_list = [np.array(a, copy=True) for a in y_data_list]
1414
+ fig._pre_derivative_ylabel = ax.get_ylabel() or ""
1415
+
1416
+ def _reset_from_derivative():
1417
+ """Reset all curves from derivative back to pre-derivative state."""
1418
+ if not hasattr(fig, '_pre_derivative_x_data_list'):
1419
+ return (False, 0, 0)
1420
+ reset_count = 0
1421
+ total_points = 0
1422
+ for i in range(min(len(fig._pre_derivative_x_data_list), len(ax.lines))):
1423
+ try:
1424
+ pre_x = fig._pre_derivative_x_data_list[i]
1425
+ pre_y = fig._pre_derivative_y_data_list[i]
1426
+ # Restore offsets
1427
+ if i < len(offsets_list):
1428
+ pre_y_with_offset = pre_y + offsets_list[i]
1429
+ else:
1430
+ pre_y_with_offset = pre_y.copy()
1431
+ ax.lines[i].set_data(pre_x, pre_y_with_offset)
1432
+ x_data_list[i] = pre_x.copy()
1433
+ y_data_list[i] = pre_y_with_offset.copy()
1434
+ reset_count += 1
1435
+ total_points += len(pre_x)
1436
+ except Exception:
1437
+ pass
1438
+ # Restore y-axis label
1439
+ if hasattr(fig, '_pre_derivative_ylabel'):
1440
+ ax.set_ylabel(fig._pre_derivative_ylabel)
1441
+ # Clear derivative settings
1442
+ if hasattr(fig, '_derivative_order'):
1443
+ delattr(fig, '_derivative_order')
1444
+ return (reset_count > 0, reset_count, total_points)
1445
+
1086
1446
  def push_state(note=""):
1087
1447
  """Snapshot current editable state (before a modifying action)."""
1088
1448
  try:
@@ -1161,6 +1521,26 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1161
1521
  snap["y_data_list"] = [np.array(a, copy=True) for a in y_data_list]
1162
1522
  snap["orig_y"] = [np.array(a, copy=True) for a in orig_y]
1163
1523
  snap["offsets"] = list(offsets_list)
1524
+ # Processed data (for smooth/reduce operations)
1525
+ if hasattr(fig, '_original_x_data_list'):
1526
+ snap["original_x_data_list"] = [np.array(a, copy=True) for a in fig._original_x_data_list]
1527
+ snap["original_y_data_list"] = [np.array(a, copy=True) for a in fig._original_y_data_list]
1528
+ if hasattr(fig, '_full_processed_x_data_list'):
1529
+ snap["full_processed_x_data_list"] = [np.array(a, copy=True) for a in fig._full_processed_x_data_list]
1530
+ snap["full_processed_y_data_list"] = [np.array(a, copy=True) for a in fig._full_processed_y_data_list]
1531
+ if hasattr(fig, '_smooth_settings'):
1532
+ snap["smooth_settings"] = dict(fig._smooth_settings)
1533
+ if hasattr(fig, '_last_smooth_settings'):
1534
+ snap["last_smooth_settings"] = dict(fig._last_smooth_settings)
1535
+ # Derivative data (for derivative operations)
1536
+ if hasattr(fig, '_pre_derivative_x_data_list'):
1537
+ snap["pre_derivative_x_data_list"] = [np.array(a, copy=True) for a in fig._pre_derivative_x_data_list]
1538
+ snap["pre_derivative_y_data_list"] = [np.array(a, copy=True) for a in fig._pre_derivative_y_data_list]
1539
+ snap["pre_derivative_ylabel"] = str(getattr(fig, '_pre_derivative_ylabel', ''))
1540
+ if hasattr(fig, '_derivative_order'):
1541
+ snap["derivative_order"] = int(fig._derivative_order)
1542
+ if hasattr(fig, '_derivative_reversed'):
1543
+ snap["derivative_reversed"] = bool(fig._derivative_reversed)
1164
1544
  # Label text content
1165
1545
  snap["label_texts"] = [t.get_text() for t in label_text_objects]
1166
1546
  state_history.append(snap)
@@ -1381,12 +1761,72 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1381
1761
  offsets_list[:] = list(snap["offsets"])
1382
1762
  delta = snap.get("delta", delta)
1383
1763
 
1764
+ # Restore processed data (for smooth/reduce operations)
1765
+ if "original_x_data_list" in snap:
1766
+ fig._original_x_data_list = [np.array(a, copy=True) for a in snap["original_x_data_list"]]
1767
+ fig._original_y_data_list = [np.array(a, copy=True) for a in snap["original_y_data_list"]]
1768
+ elif hasattr(fig, '_original_x_data_list'):
1769
+ # Clear if not in snapshot
1770
+ delattr(fig, '_original_x_data_list')
1771
+ delattr(fig, '_original_y_data_list')
1772
+ if "full_processed_x_data_list" in snap:
1773
+ fig._full_processed_x_data_list = [np.array(a, copy=True) for a in snap["full_processed_x_data_list"]]
1774
+ fig._full_processed_y_data_list = [np.array(a, copy=True) for a in snap["full_processed_y_data_list"]]
1775
+ elif hasattr(fig, '_full_processed_x_data_list'):
1776
+ # Clear if not in snapshot
1777
+ delattr(fig, '_full_processed_x_data_list')
1778
+ delattr(fig, '_full_processed_y_data_list')
1779
+ if "smooth_settings" in snap:
1780
+ fig._smooth_settings = dict(snap["smooth_settings"])
1781
+ elif hasattr(fig, '_smooth_settings'):
1782
+ delattr(fig, '_smooth_settings')
1783
+ if "last_smooth_settings" in snap:
1784
+ fig._last_smooth_settings = dict(snap["last_smooth_settings"])
1785
+ elif hasattr(fig, '_last_smooth_settings'):
1786
+ delattr(fig, '_last_smooth_settings')
1787
+ # Restore derivative data (for derivative operations)
1788
+ if "pre_derivative_x_data_list" in snap:
1789
+ fig._pre_derivative_x_data_list = [np.array(a, copy=True) for a in snap["pre_derivative_x_data_list"]]
1790
+ fig._pre_derivative_y_data_list = [np.array(a, copy=True) for a in snap["pre_derivative_y_data_list"]]
1791
+ fig._pre_derivative_ylabel = str(snap.get("pre_derivative_ylabel", ""))
1792
+ elif hasattr(fig, '_pre_derivative_x_data_list'):
1793
+ delattr(fig, '_pre_derivative_x_data_list')
1794
+ delattr(fig, '_pre_derivative_y_data_list')
1795
+ if hasattr(fig, '_pre_derivative_ylabel'):
1796
+ delattr(fig, '_pre_derivative_ylabel')
1797
+ if "derivative_order" in snap:
1798
+ fig._derivative_order = int(snap["derivative_order"])
1799
+ elif hasattr(fig, '_derivative_order'):
1800
+ delattr(fig, '_derivative_order')
1801
+ if "derivative_reversed" in snap:
1802
+ fig._derivative_reversed = bool(snap["derivative_reversed"])
1803
+ elif hasattr(fig, '_derivative_reversed'):
1804
+ delattr(fig, '_derivative_reversed')
1805
+ # Restore y-axis label if derivative was applied
1806
+ if "derivative_order" in snap:
1807
+ try:
1808
+ current_ylabel = ax.get_ylabel() or ""
1809
+ order = int(snap["derivative_order"])
1810
+ is_reversed = snap.get("derivative_reversed", False)
1811
+ new_ylabel = _update_ylabel_for_derivative(order, current_ylabel, is_reversed=is_reversed)
1812
+ ax.set_ylabel(new_ylabel)
1813
+ except Exception:
1814
+ pass
1815
+
1384
1816
  # Recalculate y_data_list from orig_y and offsets_list to ensure consistency
1817
+ # Ensure lists have the same length before assigning
1818
+ max_len = max(len(orig_y), len(y_data_list), len(offsets_list))
1819
+ while len(orig_y) < max_len:
1820
+ orig_y.append(np.array([]))
1821
+ while len(y_data_list) < max_len:
1822
+ y_data_list.append(np.array([]))
1823
+ while len(offsets_list) < max_len:
1824
+ offsets_list.append(0.0)
1385
1825
  for i in range(len(orig_y)):
1386
1826
  if i < len(offsets_list):
1387
1827
  y_data_list[i] = orig_y[i] + offsets_list[i]
1388
1828
  else:
1389
- y_data_list[i] = orig_y[i].copy()
1829
+ y_data_list[i] = orig_y[i].copy() if orig_y[i].size > 0 else np.array([])
1390
1830
 
1391
1831
  # Update line data with restored values
1392
1832
  for i in range(min(len(ax.lines), len(x_data_list), len(y_data_list))):
@@ -1423,7 +1863,15 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1423
1863
  pass
1424
1864
  if _bp is not None and 'show_cif_hkl' in snap:
1425
1865
  try:
1426
- setattr(_bp, 'show_cif_hkl', bool(snap['show_cif_hkl']))
1866
+ new_state = bool(snap['show_cif_hkl'])
1867
+ setattr(_bp, 'show_cif_hkl', new_state)
1868
+ # Also store in __main__ module so draw function can access it
1869
+ try:
1870
+ _bp_module = sys.modules.get('__main__')
1871
+ if _bp_module is not None:
1872
+ setattr(_bp_module, 'show_cif_hkl', new_state)
1873
+ except Exception:
1874
+ pass
1427
1875
  except Exception:
1428
1876
  pass
1429
1877
  if _bp is not None and 'show_cif_titles' in snap:
@@ -1508,8 +1956,16 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1508
1956
  try:
1509
1957
  # Flip visibility flag in batplot module
1510
1958
  cur = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else False
1959
+ new_state = not cur
1511
1960
  if _bp is not None:
1512
- setattr(_bp, 'show_cif_hkl', not cur)
1961
+ setattr(_bp, 'show_cif_hkl', new_state)
1962
+ # Also store in __main__ module so draw function can access it
1963
+ try:
1964
+ _bp_module = sys.modules.get('__main__')
1965
+ if _bp_module is not None:
1966
+ setattr(_bp_module, 'show_cif_hkl', new_state)
1967
+ except Exception:
1968
+ pass
1513
1969
  # Avoid re-entrant extension while redrawing
1514
1970
  prev_ext = bool(getattr(_bp, 'cif_extend_suspended', False)) if _bp is not None else False
1515
1971
  if _bp is not None:
@@ -1636,6 +2092,141 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
1636
2092
  except Exception as e:
1637
2093
  print(f"Error toggling crosshair: {e}")
1638
2094
  continue
2095
+ elif key == 'os':
2096
+ # Quick overwrite of last saved session (.pkl)
2097
+ try:
2098
+ last_session_path = getattr(fig, '_last_session_save_path', None)
2099
+ if not last_session_path:
2100
+ print("No previous session save found.")
2101
+ continue
2102
+ if not os.path.exists(last_session_path):
2103
+ print(f"Previous save file not found: {last_session_path}")
2104
+ continue
2105
+ yn = _safe_input(f"Overwrite session '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
2106
+ if yn != 'y':
2107
+ print("Canceled.")
2108
+ continue
2109
+ _bp_dump_session(
2110
+ last_session_path,
2111
+ fig=fig,
2112
+ ax=ax,
2113
+ x_data_list=x_data_list,
2114
+ y_data_list=y_data_list,
2115
+ orig_y=orig_y,
2116
+ offsets_list=offsets_list,
2117
+ labels=labels,
2118
+ delta=delta,
2119
+ args=args,
2120
+ tick_state=tick_state,
2121
+ cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
2122
+ cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
2123
+ cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
2124
+ show_cif_hkl=(bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else False),
2125
+ show_cif_titles=(bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True),
2126
+ skip_confirm=True,
2127
+ )
2128
+ fig._last_session_save_path = last_session_path
2129
+ print(f"Overwritten session to {last_session_path}")
2130
+ except Exception as e:
2131
+ print(f"Error overwriting session: {e}")
2132
+ continue
2133
+ elif key in ('ops', 'opsg'):
2134
+ # Quick overwrite of last exported style file (.bps / .bpsg)
2135
+ try:
2136
+ last_style_path = getattr(fig, '_last_style_export_path', None)
2137
+ if not last_style_path:
2138
+ print("No previous style export found.")
2139
+ continue
2140
+ if not os.path.exists(last_style_path):
2141
+ print(f"Previous style file not found: {last_style_path}")
2142
+ continue
2143
+ if key == 'ops':
2144
+ mode = 'ps'
2145
+ label = "style-only"
2146
+ else:
2147
+ mode = 'psg'
2148
+ label = "style+geometry"
2149
+ yn = _safe_input(
2150
+ f"Overwrite {label} file '{os.path.basename(last_style_path)}'? (y/n): "
2151
+ ).strip().lower()
2152
+ if yn != 'y':
2153
+ print("Canceled.")
2154
+ continue
2155
+ exported = export_style_config(
2156
+ None,
2157
+ base_path=None,
2158
+ overwrite_path=last_style_path,
2159
+ force_kind=mode,
2160
+ )
2161
+ if exported:
2162
+ fig._last_style_export_path = exported
2163
+ print(f"Overwritten {label} style to {exported}")
2164
+ except Exception as e:
2165
+ print(f"Error overwriting style: {e}")
2166
+ continue
2167
+ elif key == 'oe':
2168
+ # Quick overwrite of last exported figure
2169
+ try:
2170
+ last_figure_path = getattr(fig, '_last_figure_export_path', None)
2171
+ if not last_figure_path:
2172
+ print("No previous figure export found.")
2173
+ continue
2174
+ if not os.path.exists(last_figure_path):
2175
+ print(f"Previous export file not found: {last_figure_path}")
2176
+ continue
2177
+ yn = _safe_input(
2178
+ f"Overwrite figure '{os.path.basename(last_figure_path)}'? (y/n): "
2179
+ ).strip().lower()
2180
+ if yn != 'y':
2181
+ print("Canceled.")
2182
+ continue
2183
+ export_target = last_figure_path
2184
+ from .utils import ensure_exact_case_filename
2185
+ export_target = ensure_exact_case_filename(export_target)
2186
+ # Temporarily remove numbering for export
2187
+ for i, txt in enumerate(label_text_objects):
2188
+ txt.set_text(labels[i])
2189
+ _, _ext = os.path.splitext(export_target)
2190
+ if _ext.lower() == '.svg':
2191
+ try:
2192
+ _fig_fc = fig.get_facecolor()
2193
+ except Exception:
2194
+ _fig_fc = None
2195
+ try:
2196
+ _ax_fc = ax.get_facecolor()
2197
+ except Exception:
2198
+ _ax_fc = None
2199
+ try:
2200
+ if getattr(fig, 'patch', None) is not None:
2201
+ fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
2202
+ if getattr(ax, 'patch', None) is not None:
2203
+ ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
2204
+ except Exception:
2205
+ pass
2206
+ try:
2207
+ fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
2208
+ finally:
2209
+ try:
2210
+ if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
2211
+ fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
2212
+ except Exception:
2213
+ pass
2214
+ try:
2215
+ if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
2216
+ ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
2217
+ except Exception:
2218
+ pass
2219
+ else:
2220
+ fig.savefig(export_target, dpi=300)
2221
+ print(f"Figure saved to {export_target}")
2222
+ fig._last_figure_export_path = export_target
2223
+ # Restore numbering
2224
+ for i, txt in enumerate(label_text_objects):
2225
+ txt.set_text(f"{i+1}: {labels[i]}")
2226
+ fig.canvas.draw()
2227
+ except Exception as e:
2228
+ print(f"Error overwriting figure: {e}")
2229
+ continue
1639
2230
  elif key == 's':
1640
2231
  # Save current interactive session with numbered overwrite picker
1641
2232
  try:
@@ -2433,7 +3024,49 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2433
3024
  print("Invalid value, ignored.")
2434
3025
  continue
2435
3026
  push_state("xrange")
2436
- ax.set_xlim(current_xlim[0], new_upper)
3027
+ new_min = current_xlim[0]
3028
+ new_max = new_upper
3029
+ ax.set_xlim(new_min, new_max)
3030
+ # Re-filter data from original processed data if available
3031
+ data_is_processed = (hasattr(fig, '_original_x_data_list') or
3032
+ hasattr(fig, '_smooth_settings') or
3033
+ hasattr(fig, '_derivative_order') or
3034
+ hasattr(fig, '_pre_derivative_x_data_list'))
3035
+ if data_is_processed and hasattr(fig, '_original_x_data_list'):
3036
+ for i in range(len(labels)):
3037
+ if i < len(fig._original_x_data_list):
3038
+ x_current = fig._original_x_data_list[i]
3039
+ y_current = fig._original_y_data_list[i]
3040
+ if i < len(offsets_list):
3041
+ y_current_no_offset = y_current - offsets_list[i]
3042
+ else:
3043
+ y_current_no_offset = y_current.copy()
3044
+ mask = (x_current >= new_min) & (x_current <= new_max)
3045
+ x_sub = np.asarray(x_current[mask], dtype=float).flatten()
3046
+ y_sub = np.asarray(y_current_no_offset[mask], dtype=float).flatten()
3047
+ if x_sub.size == 0:
3048
+ ax.lines[i].set_data([], [])
3049
+ x_data_list[i] = np.array([], dtype=float)
3050
+ y_data_list[i] = np.array([], dtype=float)
3051
+ if i < len(orig_y):
3052
+ orig_y[i] = np.array([], dtype=float)
3053
+ continue
3054
+ if i < len(offsets_list):
3055
+ y_sub = y_sub + offsets_list[i]
3056
+ ax.lines[i].set_data(x_sub, y_sub)
3057
+ x_data_list[i] = np.asarray(x_sub, dtype=float).flatten()
3058
+ y_data_list[i] = np.asarray(y_sub, dtype=float).flatten()
3059
+ # Update orig_y with robust method
3060
+ while len(orig_y) <= i:
3061
+ orig_y.append(np.array([], dtype=float))
3062
+ try:
3063
+ y_no_offset = y_sub - offsets_list[i] if i < len(offsets_list) else y_sub
3064
+ y_no_offset_1d = np.array(y_no_offset, dtype=float).ravel()
3065
+ if i < len(orig_y):
3066
+ del orig_y[i]
3067
+ orig_y.insert(i, y_no_offset_1d)
3068
+ except Exception:
3069
+ pass
2437
3070
  ax.relim()
2438
3071
  ax.autoscale_view(scalex=False, scaley=True)
2439
3072
  update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
@@ -2464,7 +3097,49 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2464
3097
  print("Invalid value, ignored.")
2465
3098
  continue
2466
3099
  push_state("xrange")
2467
- ax.set_xlim(new_lower, current_xlim[1])
3100
+ new_min = new_lower
3101
+ new_max = current_xlim[1]
3102
+ ax.set_xlim(new_min, new_max)
3103
+ # Re-filter data from original processed data if available
3104
+ data_is_processed = (hasattr(fig, '_original_x_data_list') or
3105
+ hasattr(fig, '_smooth_settings') or
3106
+ hasattr(fig, '_derivative_order') or
3107
+ hasattr(fig, '_pre_derivative_x_data_list'))
3108
+ if data_is_processed and hasattr(fig, '_original_x_data_list'):
3109
+ for i in range(len(labels)):
3110
+ if i < len(fig._original_x_data_list):
3111
+ x_current = fig._original_x_data_list[i]
3112
+ y_current = fig._original_y_data_list[i]
3113
+ if i < len(offsets_list):
3114
+ y_current_no_offset = y_current - offsets_list[i]
3115
+ else:
3116
+ y_current_no_offset = y_current.copy()
3117
+ mask = (x_current >= new_min) & (x_current <= new_max)
3118
+ x_sub = np.asarray(x_current[mask], dtype=float).flatten()
3119
+ y_sub = np.asarray(y_current_no_offset[mask], dtype=float).flatten()
3120
+ if x_sub.size == 0:
3121
+ ax.lines[i].set_data([], [])
3122
+ x_data_list[i] = np.array([], dtype=float)
3123
+ y_data_list[i] = np.array([], dtype=float)
3124
+ if i < len(orig_y):
3125
+ orig_y[i] = np.array([], dtype=float)
3126
+ continue
3127
+ if i < len(offsets_list):
3128
+ y_sub = y_sub + offsets_list[i]
3129
+ ax.lines[i].set_data(x_sub, y_sub)
3130
+ x_data_list[i] = np.asarray(x_sub, dtype=float).flatten()
3131
+ y_data_list[i] = np.asarray(y_sub, dtype=float).flatten()
3132
+ # Update orig_y with robust method
3133
+ while len(orig_y) <= i:
3134
+ orig_y.append(np.array([], dtype=float))
3135
+ try:
3136
+ y_no_offset = y_sub - offsets_list[i] if i < len(offsets_list) else y_sub
3137
+ y_no_offset_1d = np.array(y_no_offset, dtype=float).ravel()
3138
+ if i < len(orig_y):
3139
+ del orig_y[i]
3140
+ orig_y.insert(i, y_no_offset_1d)
3141
+ except Exception:
3142
+ pass
2468
3143
  ax.relim()
2469
3144
  ax.autoscale_view(scalex=False, scaley=True)
2470
3145
  update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
@@ -2482,22 +3157,266 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2482
3157
  print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
2483
3158
  continue
2484
3159
  if rng.lower() == 'a':
2485
- # Auto: restore original range from x_full_list
3160
+ # Auto: restore original range from CURRENT PROCESSED data (not original unprocessed)
2486
3161
  push_state("xrange-auto")
2487
- if x_full_list:
3162
+ try:
3163
+ # Check if data has been processed
3164
+ data_is_processed = (hasattr(fig, '_original_x_data_list') or
3165
+ hasattr(fig, '_smooth_settings') or
3166
+ hasattr(fig, '_derivative_order') or
3167
+ hasattr(fig, '_pre_derivative_x_data_list'))
3168
+ if data_is_processed and x_data_list and all(xd.size > 0 for xd in x_data_list):
3169
+ # Use CURRENT processed data to determine full range (preserves all processing)
3170
+ print(f"DEBUG: Using current processed data for auto restore (has {len(x_data_list)} curves)")
3171
+ new_min = min(xd.min() for xd in x_data_list if xd.size)
3172
+ new_max = max(xd.max() for xd in x_data_list if xd.size)
3173
+ print(f"DEBUG: Processed data range: {new_min:.6g} to {new_max:.6g}")
3174
+ elif x_full_list:
3175
+ print(f"DEBUG: Using original full data (no processing detected)")
3176
+ new_min = min(xf.min() for xf in x_full_list if xf.size)
3177
+ new_max = max(xf.max() for xf in x_full_list if xf.size)
3178
+ else:
3179
+ print("No original data available.")
3180
+ continue
3181
+ # Restore all data - use CURRENT PROCESSED data (preserves all processing steps)
3182
+ for i in range(len(labels)):
3183
+ if data_is_processed and hasattr(fig, '_full_processed_x_data_list') and i < len(fig._full_processed_x_data_list):
3184
+ # Use FULL processed data (preserves all processing: reduce + smooth + derivative)
3185
+ print(f"DEBUG: Auto restore curve {i+1}: Using full processed data ({len(fig._full_processed_x_data_list[i])} points)")
3186
+ xf = np.asarray(fig._full_processed_x_data_list[i], dtype=float).flatten()
3187
+ yf = np.asarray(fig._full_processed_y_data_list[i], dtype=float).flatten()
3188
+ yf_raw = yf - (offsets_list[i] if i < len(offsets_list) else 0.0)
3189
+ elif data_is_processed and i < len(x_data_list) and x_data_list[i].size > 0:
3190
+ # Fallback: use current processed data
3191
+ print(f"DEBUG: Auto restore curve {i+1}: Using current processed data ({len(x_data_list[i])} points)")
3192
+ xf = np.asarray(x_data_list[i], dtype=float).flatten()
3193
+ yf = np.asarray(y_data_list[i], dtype=float).flatten()
3194
+ yf_raw = yf - (offsets_list[i] if i < len(offsets_list) else 0.0)
3195
+ else:
3196
+ # Use full original data (no processing)
3197
+ print(f"DEBUG: Auto restore curve {i+1}: Using original full data")
3198
+ xf = x_full_list[i] if i < len(x_full_list) else x_data_list[i]
3199
+ yf_raw = raw_y_full_list[i] if i < len(raw_y_full_list) else (orig_y[i] if i < len(orig_y) else y_data_list[i])
3200
+ xf = np.asarray(xf, dtype=float).flatten()
3201
+ yf_raw = np.asarray(yf_raw, dtype=float).flatten()
3202
+ mask = (xf >= new_min) & (xf <= new_max)
3203
+ x_sub = np.asarray(xf[mask], dtype=float).flatten()
3204
+ y_sub_raw = np.asarray(yf_raw[mask], dtype=float).flatten()
3205
+ if x_sub.size == 0:
3206
+ ax.lines[i].set_data([], [])
3207
+ x_data_list[i] = np.array([], dtype=float)
3208
+ y_data_list[i] = np.array([], dtype=float)
3209
+ if i < len(orig_y):
3210
+ orig_y[i] = np.array([], dtype=float)
3211
+ continue
3212
+ should_normalize = args.stack or getattr(args, 'norm', False)
3213
+ if should_normalize:
3214
+ if y_sub_raw.size:
3215
+ y_min = float(y_sub_raw.min())
3216
+ y_max = float(y_sub_raw.max())
3217
+ span = y_max - y_min
3218
+ if span > 0:
3219
+ y_sub_norm = (y_sub_raw - y_min) / span
3220
+ else:
3221
+ y_sub_norm = np.zeros_like(y_sub_raw)
3222
+ else:
3223
+ y_sub_norm = y_sub_raw
3224
+ else:
3225
+ y_sub_norm = y_sub_raw
3226
+ offset_val = offsets_list[i] if i < len(offsets_list) else 0.0
3227
+ y_with_offset = y_sub_norm + offset_val
3228
+ ax.lines[i].set_data(x_sub, y_with_offset)
3229
+ x_data_list[i] = np.asarray(x_sub, dtype=float).flatten()
3230
+ y_data_list[i] = np.asarray(y_with_offset, dtype=float).flatten()
3231
+ # Ensure orig_y list has enough elements
3232
+ while len(orig_y) <= i:
3233
+ orig_y.append(np.array([], dtype=float))
3234
+ # Create a new 1D array - ensure it's a proper numpy array
3235
+ # Handle all edge cases: scalar, 0-d array, multi-d array
3236
+ try:
3237
+ if isinstance(y_sub_norm, np.ndarray):
3238
+ if y_sub_norm.ndim == 0:
3239
+ y_sub_norm_1d = np.array([float(y_sub_norm)], dtype=float)
3240
+ else:
3241
+ y_sub_norm_1d = np.array(y_sub_norm.flatten(), dtype=float, copy=True)
3242
+ else:
3243
+ # It's a scalar or list
3244
+ y_sub_norm_1d = np.array(y_sub_norm, dtype=float).flatten()
3245
+ # Ensure it's 1D
3246
+ if y_sub_norm_1d.ndim != 1:
3247
+ y_sub_norm_1d = y_sub_norm_1d.reshape(-1)
3248
+ # Replace list element - delete old one first if needed
3249
+ if i < len(orig_y):
3250
+ del orig_y[i]
3251
+ orig_y.insert(i, y_sub_norm_1d)
3252
+ except Exception as e:
3253
+ # Fallback: just create a simple array
3254
+ try:
3255
+ y_sub_norm_1d = np.array(y_sub_norm, dtype=float).ravel()
3256
+ if i < len(orig_y):
3257
+ orig_y[i] = y_sub_norm_1d
3258
+ else:
3259
+ orig_y.append(y_sub_norm_1d)
3260
+ except Exception:
3261
+ # Last resort: skip orig_y update
3262
+ pass
3263
+ ax.set_xlim(new_min, new_max)
3264
+ ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
3265
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
3266
+ try:
3267
+ if hasattr(ax, '_cif_extend_func'):
3268
+ ax._cif_extend_func(ax.get_xlim()[1])
3269
+ except Exception:
3270
+ pass
3271
+ try:
3272
+ if hasattr(ax, '_cif_draw_func'):
3273
+ ax._cif_draw_func()
3274
+ except Exception:
3275
+ pass
3276
+ fig.canvas.draw()
3277
+ print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
3278
+ except Exception as e:
3279
+ print(f"Error during auto restore: {e}")
3280
+ import traceback
3281
+ traceback.print_exc()
3282
+ continue
3283
+ push_state("xrange")
3284
+ if rng.lower() == 'full':
3285
+ # Use full data if available, otherwise use current processed data
3286
+ if x_full_list and all(xf.size > 0 for xf in x_full_list):
2488
3287
  new_min = min(xf.min() for xf in x_full_list if xf.size)
2489
3288
  new_max = max(xf.max() for xf in x_full_list if xf.size)
2490
3289
  else:
2491
- print("No original data available.")
2492
- continue
2493
- # Restore all data
2494
- for i in range(len(labels)):
2495
- xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
2496
- mask = (xf>=new_min) & (xf<=new_max)
2497
- x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
3290
+ new_min = min(xd.min() for xd in x_data_list if xd.size)
3291
+ new_max = max(xd.max() for xd in x_data_list if xd.size)
3292
+ else:
3293
+ new_min, new_max = map(float, rng.split())
3294
+ ax.set_xlim(new_min, new_max)
3295
+ # Check if data has been processed (smooth/derivative/reduce)
3296
+ data_is_processed = (hasattr(fig, '_original_x_data_list') or
3297
+ hasattr(fig, '_smooth_settings') or
3298
+ hasattr(fig, '_derivative_order') or
3299
+ hasattr(fig, '_pre_derivative_x_data_list'))
3300
+
3301
+ for i in range(len(labels)):
3302
+ if data_is_processed and i < len(x_data_list) and x_data_list[i].size > 0:
3303
+ # Use full processed data if available (allows expansion), otherwise use current filtered data
3304
+ curr_x = np.asarray(x_data_list[i], dtype=float)
3305
+ curr_min = curr_x.min() if curr_x.size > 0 else float('inf')
3306
+ curr_max = curr_x.max() if curr_x.size > 0 else float('-inf')
3307
+
3308
+ # Check if we need full processed data (for expansion beyond current filter)
3309
+ need_full = (new_min < curr_min or new_max > curr_max)
3310
+
3311
+ if need_full and hasattr(fig, '_full_processed_x_data_list') and i < len(fig._full_processed_x_data_list):
3312
+ # Use full processed data to allow expansion
3313
+ full_x = np.asarray(fig._full_processed_x_data_list[i], dtype=float)
3314
+ if full_x.size > 0:
3315
+ full_min = full_x.min()
3316
+ full_max = full_x.max()
3317
+ print(f"DEBUG: Curve {i+1}: Expanding range ({curr_min:.6g}-{curr_max:.6g} -> {new_min:.6g}-{new_max:.6g}), using full processed data (range {full_min:.6g} to {full_max:.6g})")
3318
+ x_current = full_x
3319
+ y_current = np.asarray(fig._full_processed_y_data_list[i], dtype=float)
3320
+ else:
3321
+ print(f"DEBUG: Curve {i+1}: Full processed data empty, using current data")
3322
+ x_current = curr_x
3323
+ y_current = np.asarray(y_data_list[i], dtype=float)
3324
+ else:
3325
+ print(f"DEBUG: Curve {i+1}: Using current processed data (range {curr_min:.6g} to {curr_max:.6g}, requested {new_min:.6g} to {new_max:.6g})")
3326
+ x_current = curr_x
3327
+ y_current = np.asarray(y_data_list[i], dtype=float)
3328
+ # Remove offset for filtering
3329
+ if i < len(offsets_list):
3330
+ y_current_no_offset = y_current - offsets_list[i]
3331
+ else:
3332
+ y_current_no_offset = y_current.copy()
3333
+ mask = (x_current >= new_min) & (x_current <= new_max)
3334
+ x_sub = np.asarray(x_current[mask], dtype=float).flatten()
3335
+ y_sub = np.asarray(y_current_no_offset[mask], dtype=float).flatten()
3336
+ if x_sub.size == 0:
3337
+ ax.lines[i].set_data([], [])
3338
+ x_data_list[i] = np.array([], dtype=float)
3339
+ y_data_list[i] = np.array([], dtype=float)
3340
+ if i < len(orig_y):
3341
+ orig_y[i] = np.array([], dtype=float)
3342
+ continue
3343
+ # Restore offset
3344
+ if i < len(offsets_list):
3345
+ y_sub = y_sub + offsets_list[i]
3346
+ ax.lines[i].set_data(x_sub, y_sub)
3347
+ x_data_list[i] = np.asarray(x_sub, dtype=float).flatten()
3348
+ y_data_list[i] = np.asarray(y_sub, dtype=float).flatten()
3349
+ # Update orig_y
3350
+ # Update orig_y with robust method
3351
+ while len(orig_y) <= i:
3352
+ orig_y.append(np.array([], dtype=float))
3353
+ try:
3354
+ y_no_offset = y_sub - offsets_list[i] if i < len(offsets_list) else y_sub
3355
+ y_no_offset_1d = np.array(y_no_offset, dtype=float).ravel()
3356
+ if i < len(orig_y):
3357
+ del orig_y[i]
3358
+ orig_y.insert(i, y_no_offset_1d)
3359
+ except Exception:
3360
+ pass
3361
+ elif data_is_processed and i < len(x_data_list) and x_data_list[i].size > 0:
3362
+ # Fallback: use current data if _original_x_data_list not available
3363
+ x_current = np.asarray(x_data_list[i], dtype=float)
3364
+ y_current = np.asarray(y_data_list[i], dtype=float)
3365
+ mask = (x_current >= new_min) & (x_current <= new_max)
3366
+ x_sub = np.asarray(x_current[mask], dtype=float).flatten()
3367
+ y_sub = np.asarray(y_current[mask], dtype=float).flatten()
2498
3368
  if x_sub.size == 0:
2499
3369
  ax.lines[i].set_data([], [])
2500
- y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
3370
+ x_data_list[i] = np.array([], dtype=float)
3371
+ y_data_list[i] = np.array([], dtype=float)
3372
+ if i < len(orig_y):
3373
+ orig_y[i] = np.array([], dtype=float)
3374
+ continue
3375
+ ax.lines[i].set_data(x_sub, y_sub)
3376
+ x_data_list[i] = np.asarray(x_sub, dtype=float).flatten()
3377
+ y_data_list[i] = np.asarray(y_sub, dtype=float).flatten()
3378
+ # Update orig_y - use same robust method as in 'a' branch
3379
+ while len(orig_y) <= i:
3380
+ orig_y.append(np.array([], dtype=float))
3381
+ try:
3382
+ y_no_offset = y_sub - offsets_list[i] if i < len(offsets_list) else y_sub
3383
+ if isinstance(y_no_offset, np.ndarray):
3384
+ if y_no_offset.ndim == 0:
3385
+ y_no_offset_1d = np.array([float(y_no_offset)], dtype=float)
3386
+ else:
3387
+ y_no_offset_1d = np.array(y_no_offset.flatten(), dtype=float, copy=True)
3388
+ else:
3389
+ y_no_offset_1d = np.array(y_no_offset, dtype=float).flatten()
3390
+ if y_no_offset_1d.ndim != 1:
3391
+ y_no_offset_1d = y_no_offset_1d.reshape(-1)
3392
+ if i < len(orig_y):
3393
+ del orig_y[i]
3394
+ orig_y.insert(i, y_no_offset_1d)
3395
+ except Exception:
3396
+ try:
3397
+ y_no_offset = y_sub - offsets_list[i] if i < len(offsets_list) else y_sub
3398
+ y_no_offset_1d = np.array(y_no_offset, dtype=float).ravel()
3399
+ if i < len(orig_y):
3400
+ orig_y[i] = y_no_offset_1d
3401
+ else:
3402
+ orig_y.append(y_no_offset_1d)
3403
+ except Exception:
3404
+ pass
3405
+ else:
3406
+ # Use original full data as source
3407
+ xf = x_full_list[i] if i < len(x_full_list) else x_data_list[i]
3408
+ yf_raw = raw_y_full_list[i] if i < len(raw_y_full_list) else (orig_y[i] if i < len(orig_y) else y_data_list[i])
3409
+ mask = (xf >= new_min) & (xf <= new_max)
3410
+ x_sub = np.array(xf[mask], copy=True)
3411
+ y_sub_raw = np.array(yf_raw[mask], copy=True)
3412
+ if x_sub.size == 0:
3413
+ ax.lines[i].set_data([], [])
3414
+ x_data_list[i] = np.array([])
3415
+ y_data_list[i] = np.array([])
3416
+ if i < len(orig_y):
3417
+ orig_y[i] = np.array([])
3418
+ continue
3419
+ # Auto-normalize for --stack mode, or explicit --norm flag
2501
3420
  should_normalize = args.stack or getattr(args, 'norm', False)
2502
3421
  if should_normalize:
2503
3422
  if y_sub_raw.size:
@@ -2512,63 +3431,13 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2512
3431
  y_sub_norm = y_sub_raw
2513
3432
  else:
2514
3433
  y_sub_norm = y_sub_raw
2515
- offset_val = offsets_list[i]
3434
+ offset_val = offsets_list[i] if i < len(offsets_list) else 0.0
2516
3435
  y_with_offset = y_sub_norm + offset_val
2517
3436
  ax.lines[i].set_data(x_sub, y_with_offset)
2518
3437
  x_data_list[i] = x_sub
2519
3438
  y_data_list[i] = y_with_offset
2520
- orig_y[i] = y_sub_norm
2521
- ax.set_xlim(new_min, new_max)
2522
- ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
2523
- update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2524
- try:
2525
- if hasattr(ax, '_cif_extend_func'):
2526
- ax._cif_extend_func(ax.get_xlim()[1])
2527
- except Exception:
2528
- pass
2529
- try:
2530
- if hasattr(ax, '_cif_draw_func'):
2531
- ax._cif_draw_func()
2532
- except Exception:
2533
- pass
2534
- fig.canvas.draw()
2535
- print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
2536
- continue
2537
- push_state("xrange")
2538
- if rng.lower() == 'full':
2539
- new_min = min(xf.min() for xf in x_full_list if xf.size)
2540
- new_max = max(xf.max() for xf in x_full_list if xf.size)
2541
- else:
2542
- new_min, new_max = map(float, rng.split())
2543
- ax.set_xlim(new_min, new_max)
2544
- for i in range(len(labels)):
2545
- xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
2546
- mask = (xf>=new_min) & (xf<=new_max)
2547
- x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
2548
- if x_sub.size == 0:
2549
- ax.lines[i].set_data([], [])
2550
- y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
2551
- # Auto-normalize for --stack mode, or explicit --norm flag
2552
- should_normalize = args.stack or getattr(args, 'norm', False)
2553
- if should_normalize:
2554
- if y_sub_raw.size:
2555
- y_min = float(y_sub_raw.min())
2556
- y_max = float(y_sub_raw.max())
2557
- span = y_max - y_min
2558
- if span > 0:
2559
- y_sub_norm = (y_sub_raw - y_min) / span
2560
- else:
2561
- y_sub_norm = np.zeros_like(y_sub_raw)
2562
- else:
2563
- y_sub_norm = y_sub_raw
2564
- else:
2565
- y_sub_norm = y_sub_raw
2566
- offset_val = offsets_list[i]
2567
- y_with_offset = y_sub_norm + offset_val
2568
- ax.lines[i].set_data(x_sub, y_with_offset)
2569
- x_data_list[i] = x_sub
2570
- y_data_list[i] = y_with_offset
2571
- orig_y[i] = y_sub_norm
3439
+ if i < len(orig_y):
3440
+ orig_y[i] = y_sub_norm
2572
3441
  ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
2573
3442
  update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
2574
3443
  # Extend CIF ticks after x-range change
@@ -2697,7 +3566,98 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
2697
3566
  print(f"Y range set to ({float(ymin)}, {float(ymax)})")
2698
3567
  except Exception as e:
2699
3568
  print(f"Error setting Y-axis range: {e}")
2700
- elif key == 'd': # <-- DELTA / OFFSET HANDLER (now only reachable if not args.stack)
3569
+ elif key == 'd': # <-- DERIVATIVE HANDLER
3570
+ while True:
3571
+ try:
3572
+ print("\n\033[1mDerivative Menu\033[0m")
3573
+ print("Commands:")
3574
+ print(" 1: Calculate 1st derivative (dy/dx)")
3575
+ print(" 2: Calculate 2nd derivative (d²y/dx²)")
3576
+ print(" 3: Calculate reversed 1st derivative (dx/dy)")
3577
+ print(" 4: Calculate reversed 2nd derivative (d²x/dy²)")
3578
+ print(" reset: Reset to data before derivative")
3579
+ print(" q: back to main menu")
3580
+ sub = _safe_input(colorize_prompt("d> ")).strip().lower()
3581
+ if not sub or sub == 'q':
3582
+ break
3583
+ if sub == 'reset':
3584
+ push_state("derivative-reset")
3585
+ success, reset_count, total_points = _reset_from_derivative()
3586
+ if success:
3587
+ print(f"Reset {reset_count} curve(s) from derivative to original data ({total_points} total points restored).")
3588
+ ax.relim()
3589
+ ax.autoscale_view(scalex=False, scaley=True)
3590
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
3591
+ _apply_data_changes()
3592
+ else:
3593
+ print("No derivative data to reset.")
3594
+ continue
3595
+ if sub in ('1', '2', '3', '4'):
3596
+ try:
3597
+ option = int(sub)
3598
+ is_reversed = (option == 3 or option == 4)
3599
+ order = 1 if option in (1, 3) else 2
3600
+ push_state(f"derivative-{option}")
3601
+ _ensure_pre_derivative_data()
3602
+ processed = 0
3603
+ total_points = 0
3604
+ for i in range(len(x_data_list)):
3605
+ try:
3606
+ # Use current data (may already be processed)
3607
+ current_x = x_data_list[i].copy()
3608
+ current_y = y_data_list[i].copy()
3609
+ # Remove offset for processing
3610
+ if i < len(offsets_list):
3611
+ current_y_no_offset = current_y - offsets_list[i]
3612
+ else:
3613
+ current_y_no_offset = current_y.copy()
3614
+ n_points = len(current_y_no_offset)
3615
+ if n_points < 2:
3616
+ print(f"Curve {i+1} has too few points (<2) for derivative calculation.")
3617
+ continue
3618
+ # Calculate derivative
3619
+ if is_reversed:
3620
+ derivative_y = _calculate_reversed_derivative(current_x, current_y_no_offset, order)
3621
+ else:
3622
+ derivative_y = _calculate_derivative(current_x, current_y_no_offset, order)
3623
+ if len(derivative_y) > 0:
3624
+ # Restore offset
3625
+ if i < len(offsets_list):
3626
+ derivative_y = derivative_y + offsets_list[i]
3627
+ # Update data (keep same x, replace y with derivative)
3628
+ x_data_list[i] = current_x.copy()
3629
+ y_data_list[i] = derivative_y
3630
+ processed += 1
3631
+ total_points += n_points
3632
+ except Exception as e:
3633
+ print(f"Error processing curve {i+1}: {e}")
3634
+ if processed > 0:
3635
+ # Update y-axis label
3636
+ current_ylabel = ax.get_ylabel() or ""
3637
+ new_ylabel = _update_ylabel_for_derivative(order, current_ylabel, is_reversed=is_reversed)
3638
+ ax.set_ylabel(new_ylabel)
3639
+ # Store derivative order and reversed flag
3640
+ fig._derivative_order = order
3641
+ fig._derivative_reversed = is_reversed
3642
+ # Update plot
3643
+ _apply_data_changes()
3644
+ ax.relim()
3645
+ ax.autoscale_view(scalex=False, scaley=True)
3646
+ update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
3647
+ fig.canvas.draw_idle()
3648
+ order_name = "1st" if order == 1 else "2nd"
3649
+ direction = "reversed " if is_reversed else ""
3650
+ print(f"Applied {direction}{order_name} derivative to {processed} curve(s) with {total_points} total points.")
3651
+ print(f"Y-axis label updated to: {new_ylabel}")
3652
+ _update_full_processed_data() # Store full processed data for X-range filtering
3653
+ else:
3654
+ print("No curves were processed.")
3655
+ except ValueError:
3656
+ print("Invalid input.")
3657
+ continue
3658
+ except Exception as e:
3659
+ print(f"Error in derivative menu: {e}")
3660
+ elif key == 'o': # <-- OFFSET HANDLER (now only reachable if not args.stack)
2701
3661
  print("\n\033[1mOffset adjustment menu:\033[0m")
2702
3662
  print(f" {colorize_menu('1-{}: adjust individual curve offset'.format(len(labels)))}")
2703
3663
  print(f" {colorize_menu('a: set spacing between curves')}")
@@ -3622,8 +4582,13 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3622
4582
  else:
3623
4583
  print(f" {_i}: {fname}")
3624
4584
  last_style_path = getattr(fig, '_last_style_export_path', None)
3625
- if last_style_path:
4585
+ n_style = len(style_file_list) if style_file_list else 0
4586
+ if last_style_path and n_style:
4587
+ sub = _safe_input(colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh). Press number to overwrite: ")).strip().lower()
4588
+ elif last_style_path:
3626
4589
  sub = _safe_input(colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
4590
+ elif n_style:
4591
+ sub = _safe_input(colorize_prompt("Style submenu: (e=export, q=return, r=refresh). Press number to overwrite: ")).strip().lower()
3627
4592
  else:
3628
4593
  sub = _safe_input(colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
3629
4594
  if sub == 'q':
@@ -3647,6 +4612,19 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3647
4612
  fig._last_style_export_path = exported_path
3648
4613
  style_menu_active = False
3649
4614
  break
4615
+ if sub.isdigit() and n_style and 1 <= int(sub) <= n_style:
4616
+ # Overwrite listed style file by number (same as e → export → number)
4617
+ idx = int(sub) - 1
4618
+ target_path = style_file_list[idx][1]
4619
+ fname = style_file_list[idx][0]
4620
+ yn = _safe_input(f"Overwrite '{fname}'? (y/n): ").strip().lower()
4621
+ if yn != 'y':
4622
+ continue
4623
+ exported_path = export_style_config(None, base_path=None, overwrite_path=target_path)
4624
+ if exported_path:
4625
+ fig._last_style_export_path = exported_path
4626
+ style_menu_active = False
4627
+ break
3650
4628
  if sub == 'e':
3651
4629
  save_base = choose_save_path(source_file_paths, purpose="style export")
3652
4630
  if not save_base:
@@ -3669,6 +4647,12 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3669
4647
  if not fname:
3670
4648
  print("Style import canceled.")
3671
4649
  continue
4650
+ import os
4651
+ bname = os.path.basename(fname)
4652
+ yn = _safe_input(colorize_prompt(f"Apply style '{bname}'? (y/n): ")).strip().lower()
4653
+ if yn != 'y':
4654
+ print("Style import canceled.")
4655
+ continue
3672
4656
  push_state("style-import")
3673
4657
  apply_style_config(fname)
3674
4658
  except Exception as e:
@@ -3797,6 +4781,668 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3797
4781
  fig.canvas.draw()
3798
4782
  except Exception as e:
3799
4783
  print(f"Error saving figure: {e}")
4784
+ elif key == 'sm':
4785
+ # Smoothing and data reduction menu
4786
+ _ensure_original_data()
4787
+ while True:
4788
+ print("\n\033[1mSmoothing and Data Reduction\033[0m")
4789
+ print("Commands:")
4790
+ print(" r: reduce rows (delete/merge rows based on pattern)")
4791
+ print(" s: smooth data (various smoothing methods)")
4792
+ print(" reset: reset all curves to original data")
4793
+ print(" q: back to main menu")
4794
+ sub = _safe_input(colorize_prompt("sm> ")).strip().lower()
4795
+ if not sub:
4796
+ continue
4797
+ if sub == 'q':
4798
+ break
4799
+ if sub == 'reset':
4800
+ push_state("smooth-reset")
4801
+ success, reset_count, total_points = _reset_to_original()
4802
+ if success:
4803
+ print(f"Reset {reset_count} curve(s) to original data ({total_points} total points restored).")
4804
+ _apply_data_changes()
4805
+ else:
4806
+ print("No processed data to reset.")
4807
+ continue
4808
+ if sub == 'r':
4809
+ # Reduce rows submenu
4810
+ while True:
4811
+ print("\n\033[1mReduce Rows\033[0m")
4812
+ print("Methods:")
4813
+ print(" 1: Delete N rows, then skip M rows")
4814
+ print(" 2: Delete rows with missing values")
4815
+ print(" 3: Reduce N rows with merged values (average/sum/min/max)")
4816
+ print(" q: back to smooth menu")
4817
+ method = _safe_input(colorize_prompt("sm>r> ")).strip().lower()
4818
+ if not method or method == 'q':
4819
+ break
4820
+ if method == '1':
4821
+ # Delete N rows, then skip M rows
4822
+ try:
4823
+ # Check for last settings
4824
+ last_settings = _get_last_reduce_rows_settings('delete_skip')
4825
+ last_n = last_settings.get('n')
4826
+ last_m = last_settings.get('m')
4827
+ last_start_row = last_settings.get('start_row')
4828
+
4829
+ if last_n is not None and last_m is not None and last_start_row is not None:
4830
+ use_last = _safe_input(f"Use last settings? (N={last_n}, M={last_m}, start_row={last_start_row+1}, y/n or enter N): ").strip().lower()
4831
+ # Check if user entered a number directly (skip "use last settings")
4832
+ if use_last and use_last.replace('-', '').replace('.', '').isdigit():
4833
+ n = int(float(use_last))
4834
+ if n < 1:
4835
+ print("N must be >= 1.")
4836
+ continue
4837
+ m_in = _safe_input(f"Enter M (rows to skip, default {last_m}): ").strip()
4838
+ m = int(m_in) if m_in else last_m
4839
+ if m < 0:
4840
+ print("M must be >= 0.")
4841
+ continue
4842
+ start_in = _safe_input(f"Starting row (1-based, default {last_start_row+1}): ").strip()
4843
+ start_row = int(start_in) - 1 if start_in else last_start_row
4844
+ elif use_last != 'n':
4845
+ n = last_n
4846
+ m = last_m
4847
+ start_row = last_start_row # Already 0-based in config
4848
+ else:
4849
+ n_in = _safe_input(f"Enter N (rows to delete, default {last_n}): ").strip()
4850
+ n = int(n_in) if n_in else last_n
4851
+ if n < 1:
4852
+ print("N must be >= 1.")
4853
+ continue
4854
+ m_in = _safe_input(f"Enter M (rows to skip, default {last_m}): ").strip()
4855
+ m = int(m_in) if m_in else last_m
4856
+ if m < 0:
4857
+ print("M must be >= 0.")
4858
+ continue
4859
+ start_in = _safe_input(f"Starting row (1-based, default {last_start_row+1}): ").strip()
4860
+ start_row = int(start_in) - 1 if start_in else last_start_row
4861
+ else:
4862
+ n_in = _safe_input("Enter N (rows to delete, default 1): ").strip()
4863
+ n = int(n_in) if n_in else 1
4864
+ if n < 1:
4865
+ print("N must be >= 1.")
4866
+ continue
4867
+ m_in = _safe_input("Enter M (rows to skip, default 0): ").strip()
4868
+ m = int(m_in) if m_in else 0
4869
+ if m < 0:
4870
+ print("M must be >= 0.")
4871
+ continue
4872
+ start_in = _safe_input("Starting row (1-based, default 1): ").strip()
4873
+ start_row = int(start_in) - 1 if start_in else 0
4874
+
4875
+ if start_row < 0:
4876
+ start_row = 0
4877
+ push_state("reduce-rows-delete-skip")
4878
+ _ensure_original_data()
4879
+ processed = 0
4880
+ total_before = 0
4881
+ total_after = 0
4882
+ for i in range(len(x_data_list)):
4883
+ try:
4884
+ # Use current data (may already be processed), not original
4885
+ orig_x = x_data_list[i].copy()
4886
+ orig_y = y_data_list[i].copy()
4887
+ # Remove offset for processing
4888
+ if i < len(offsets_list):
4889
+ orig_y = orig_y - offsets_list[i]
4890
+ if start_row >= len(orig_x):
4891
+ continue
4892
+ before = len(orig_x)
4893
+ # Create mask: delete n rows, then skip m rows, repeat
4894
+ mask = np.ones(len(orig_x), dtype=bool)
4895
+ idx = start_row
4896
+ while idx < len(orig_x):
4897
+ # Delete n rows
4898
+ end_del = min(idx + n, len(orig_x))
4899
+ mask[idx:end_del] = False
4900
+ idx = end_del
4901
+ # Skip m rows
4902
+ idx = min(idx + m, len(orig_x))
4903
+ new_x = orig_x[mask]
4904
+ new_y = orig_y[mask]
4905
+ after = len(new_x)
4906
+ if len(new_x) > 0:
4907
+ # Restore offset
4908
+ if i < len(offsets_list):
4909
+ new_y = new_y + offsets_list[i]
4910
+ x_data_list[i] = new_x
4911
+ y_data_list[i] = new_y
4912
+ processed += 1
4913
+ total_before += before
4914
+ total_after += after
4915
+ except Exception as e:
4916
+ print(f"Error processing curve {i+1}: {e}")
4917
+ if processed > 0:
4918
+ removed = total_before - total_after
4919
+ pct = 100 * removed / total_before if total_before else 0
4920
+ print(f"Processed {processed} curve(s); removed {removed} of {total_before} points ({pct:.1f}%).")
4921
+ _update_full_processed_data() # Store full processed data for X-range filtering
4922
+ _apply_data_changes()
4923
+ # Save settings for next time
4924
+ _save_last_reduce_rows_settings('delete_skip', {
4925
+ 'n': n,
4926
+ 'm': m,
4927
+ 'start_row': start_row # Save as 0-based
4928
+ })
4929
+ else:
4930
+ print("No curves were processed.")
4931
+ except ValueError:
4932
+ print("Invalid number.")
4933
+ continue
4934
+ if method == '2':
4935
+ # Delete rows with missing values
4936
+ try:
4937
+ # Check for last settings
4938
+ last_settings = _get_last_reduce_rows_settings('delete_missing')
4939
+ last_delete_entire_row = last_settings.get('delete_entire_row')
4940
+
4941
+ if last_delete_entire_row is not None:
4942
+ default_str = "y" if last_delete_entire_row else "n"
4943
+ use_last = _safe_input(f"Use last settings? (delete_entire_row={'y' if last_delete_entire_row else 'n'}, y/n or enter y/n): ").strip().lower()
4944
+ # Check if user entered y/n directly (skip "use last settings")
4945
+ if use_last in ('y', 'n', 'yes', 'no'):
4946
+ delete_entire_row = use_last in ('y', 'yes')
4947
+ elif use_last != 'n':
4948
+ delete_entire_row = last_delete_entire_row
4949
+ else:
4950
+ delete_entire_row_in = _safe_input(f"Delete entire row? (y/n, default {default_str}): ").strip().lower()
4951
+ delete_entire_row = delete_entire_row_in != 'n'
4952
+ else:
4953
+ delete_entire_row_in = _safe_input("Delete entire row? (y/n, default y): ").strip().lower()
4954
+ delete_entire_row = delete_entire_row_in != 'n'
4955
+ push_state("reduce-rows-delete-missing")
4956
+ _ensure_original_data()
4957
+ processed = 0
4958
+ total_before = 0
4959
+ total_after = 0
4960
+ for i in range(len(x_data_list)):
4961
+ try:
4962
+ # Use current data (may already be processed), not original
4963
+ orig_x = x_data_list[i].copy()
4964
+ orig_y = y_data_list[i].copy()
4965
+ # Remove offset for processing
4966
+ if i < len(offsets_list):
4967
+ orig_y = orig_y - offsets_list[i]
4968
+ before = len(orig_x)
4969
+ # Check for missing values (NaN or inf)
4970
+ if delete_entire_row:
4971
+ mask = np.isfinite(orig_x) & np.isfinite(orig_y)
4972
+ else:
4973
+ # Only delete missing in current column
4974
+ mask = np.isfinite(orig_y)
4975
+ new_x = orig_x[mask]
4976
+ new_y = orig_y[mask]
4977
+ after = len(new_x)
4978
+ if len(new_x) > 0:
4979
+ # Restore offset
4980
+ if i < len(offsets_list):
4981
+ new_y = new_y + offsets_list[i]
4982
+ x_data_list[i] = new_x
4983
+ y_data_list[i] = new_y
4984
+ processed += 1
4985
+ total_before += before
4986
+ total_after += after
4987
+ except Exception as e:
4988
+ print(f"Error processing curve {i+1}: {e}")
4989
+ if processed > 0:
4990
+ removed = total_before - total_after
4991
+ pct = 100 * removed / total_before if total_before else 0
4992
+ print(f"Processed {processed} curve(s); removed {removed} of {total_before} points ({pct:.1f}%).")
4993
+ _update_full_processed_data() # Store full processed data for X-range filtering
4994
+ _apply_data_changes()
4995
+ # Save settings for next time
4996
+ _save_last_reduce_rows_settings('delete_missing', {
4997
+ 'delete_entire_row': delete_entire_row
4998
+ })
4999
+ else:
5000
+ print("No curves were processed.")
5001
+ except Exception:
5002
+ print("Error processing data.")
5003
+ continue
5004
+ if method == '3':
5005
+ # Reduce N rows with merged values
5006
+ try:
5007
+ # Check for last settings
5008
+ last_settings = _get_last_reduce_rows_settings('merge')
5009
+ last_n = last_settings.get('n')
5010
+ last_merge_by = last_settings.get('merge_by')
5011
+ last_start_row = last_settings.get('start_row')
5012
+
5013
+ if last_n is not None and last_merge_by is not None and last_start_row is not None:
5014
+ merge_names = {
5015
+ '1': 'First point',
5016
+ '2': 'Last point',
5017
+ '3': 'Average',
5018
+ '4': 'Min',
5019
+ '5': 'Max',
5020
+ '6': 'Sum'
5021
+ }
5022
+ merge_name = merge_names.get(last_merge_by, 'Average')
5023
+ use_last = _safe_input(f"Use last settings? (N={last_n}, merge_by={merge_name}, start_row={last_start_row+1}, y/n or enter N): ").strip().lower()
5024
+ # Check if user entered a number directly (skip "use last settings")
5025
+ if use_last and use_last.replace('-', '').replace('.', '').isdigit():
5026
+ n = int(float(use_last))
5027
+ if n < 2:
5028
+ print("N must be >= 2.")
5029
+ continue
5030
+ print("Merge by:")
5031
+ print(" 1: First point")
5032
+ print(" 2: Last point")
5033
+ print(" 3: Average")
5034
+ print(" 4: Min")
5035
+ print(" 5: Max")
5036
+ print(" 6: Sum")
5037
+ merge_by_in = _safe_input(f"Choose (1-6, default {last_merge_by}): ").strip()
5038
+ merge_by = merge_by_in if merge_by_in else last_merge_by
5039
+ start_in = _safe_input(f"Starting row (1-based, default {last_start_row+1}): ").strip()
5040
+ start_row = int(start_in) - 1 if start_in else last_start_row
5041
+ elif use_last != 'n':
5042
+ n = last_n
5043
+ merge_by = last_merge_by
5044
+ start_row = last_start_row # Already 0-based in config
5045
+ else:
5046
+ n_in = _safe_input(f"Enter N (rows to merge, default {last_n}): ").strip()
5047
+ n = int(n_in) if n_in else last_n
5048
+ if n < 2:
5049
+ print("N must be >= 2.")
5050
+ continue
5051
+ print("Merge by:")
5052
+ print(" 1: First point")
5053
+ print(" 2: Last point")
5054
+ print(" 3: Average")
5055
+ print(" 4: Min")
5056
+ print(" 5: Max")
5057
+ print(" 6: Sum")
5058
+ merge_by_in = _safe_input(f"Choose (1-6, default {last_merge_by}): ").strip()
5059
+ merge_by = merge_by_in if merge_by_in else last_merge_by
5060
+ start_in = _safe_input(f"Starting row (1-based, default {last_start_row+1}): ").strip()
5061
+ start_row = int(start_in) - 1 if start_in else last_start_row
5062
+ else:
5063
+ n_in = _safe_input("Enter N (rows to merge, default 2): ").strip()
5064
+ n = int(n_in) if n_in else 2
5065
+ if n < 2:
5066
+ print("N must be >= 2.")
5067
+ continue
5068
+ print("Merge by:")
5069
+ print(" 1: First point")
5070
+ print(" 2: Last point")
5071
+ print(" 3: Average")
5072
+ print(" 4: Min")
5073
+ print(" 5: Max")
5074
+ print(" 6: Sum")
5075
+ merge_by_in = _safe_input("Choose (1-6, default 3): ").strip()
5076
+ merge_by = merge_by_in if merge_by_in else '3'
5077
+ start_in = _safe_input("Starting row (1-based, default 1): ").strip()
5078
+ start_row = int(start_in) - 1 if start_in else 0
5079
+
5080
+ if start_row < 0:
5081
+ start_row = 0
5082
+
5083
+ merge_funcs = {
5084
+ '1': lambda arr: arr[0] if len(arr) > 0 else np.nan,
5085
+ '2': lambda arr: arr[-1] if len(arr) > 0 else np.nan,
5086
+ '3': np.nanmean,
5087
+ '4': np.nanmin,
5088
+ '5': np.nanmax,
5089
+ '6': np.nansum,
5090
+ }
5091
+ merge_func = merge_funcs.get(merge_by, np.nanmean)
5092
+ push_state("reduce-rows-merge")
5093
+ _ensure_original_data()
5094
+ processed = 0
5095
+ total_before = 0
5096
+ total_after = 0
5097
+ for i in range(len(x_data_list)):
5098
+ try:
5099
+ # Use current data (may already be processed), not original
5100
+ orig_x = x_data_list[i].copy()
5101
+ orig_y = y_data_list[i].copy()
5102
+ # Remove offset for processing
5103
+ if i < len(offsets_list):
5104
+ orig_y = orig_y - offsets_list[i]
5105
+ if start_row >= len(orig_x):
5106
+ continue
5107
+ before = len(orig_x)
5108
+ # Group into chunks of N
5109
+ new_x_list = []
5110
+ new_y_list = []
5111
+ idx = 0
5112
+ while idx < start_row:
5113
+ new_x_list.append(orig_x[idx])
5114
+ new_y_list.append(orig_y[idx])
5115
+ idx += 1
5116
+ while idx < len(orig_x):
5117
+ end_idx = min(idx + n, len(orig_x))
5118
+ chunk_x = orig_x[idx:end_idx]
5119
+ chunk_y = orig_y[idx:end_idx]
5120
+ # Merge: use first x, merge y based on method
5121
+ new_x = chunk_x[0] if len(chunk_x) > 0 else np.nan
5122
+ new_y = merge_func(chunk_y) if len(chunk_y) > 0 else np.nan
5123
+ if np.isfinite(new_x) and np.isfinite(new_y):
5124
+ new_x_list.append(new_x)
5125
+ new_y_list.append(new_y)
5126
+ idx = end_idx
5127
+ if len(new_x_list) > 0:
5128
+ new_x = np.array(new_x_list)
5129
+ new_y = np.array(new_y_list)
5130
+ after = len(new_x)
5131
+ # Restore offset
5132
+ if i < len(offsets_list):
5133
+ new_y = new_y + offsets_list[i]
5134
+ x_data_list[i] = new_x
5135
+ y_data_list[i] = new_y
5136
+ processed += 1
5137
+ total_before += before
5138
+ total_after += after
5139
+ except Exception as e:
5140
+ print(f"Error processing curve {i+1}: {e}")
5141
+ if processed > 0:
5142
+ removed = total_before - total_after
5143
+ pct = 100 * removed / total_before if total_before else 0
5144
+ print(f"Processed {processed} curve(s); reduced {total_before} to {total_after} points (removed {removed}, {pct:.1f}%).")
5145
+ _update_full_processed_data() # Store full processed data for X-range filtering
5146
+ _apply_data_changes()
5147
+ # Save settings for next time
5148
+ _save_last_reduce_rows_settings('merge', {
5149
+ 'n': n,
5150
+ 'merge_by': merge_by,
5151
+ 'start_row': start_row # Save as 0-based
5152
+ })
5153
+ else:
5154
+ print("No curves were processed.")
5155
+ except (ValueError, KeyError):
5156
+ print("Invalid input.")
5157
+ continue
5158
+ if sub == 's':
5159
+ # Smooth submenu
5160
+ while True:
5161
+ print("\n\033[1mSmooth Data\033[0m")
5162
+ print("Methods:")
5163
+ print(" 1: Adjacent-Averaging (moving average)")
5164
+ print(" 2: Savitzky-Golay (polynomial smoothing)")
5165
+ print(" 3: FFT Filter (low-pass frequency filter)")
5166
+ print(" q: back to smooth menu")
5167
+ method = _safe_input(colorize_prompt("sm>s> ")).strip().lower()
5168
+ if not method or method == 'q':
5169
+ break
5170
+ if method == '1':
5171
+ # Adjacent-Averaging
5172
+ try:
5173
+ # Check for last settings (from config file for persistence)
5174
+ config_settings = _get_last_smooth_settings_from_config()
5175
+ session_settings = getattr(fig, '_last_smooth_settings', {})
5176
+ # Prefer config settings (persistent) over session settings
5177
+ last_settings = config_settings if config_settings.get('method') == 'adjacent_average' else session_settings
5178
+ last_method = last_settings.get('method')
5179
+ last_points = last_settings.get('points')
5180
+
5181
+ if last_method == 'adjacent_average' and last_points is not None:
5182
+ use_last = _safe_input(f"Use last settings? (points={last_points}, y/n or enter points): ").strip().lower()
5183
+ # Check if user entered a number directly (skip "use last settings")
5184
+ if use_last and use_last.replace('-', '').replace('.', '').isdigit():
5185
+ points = int(float(use_last))
5186
+ elif use_last != 'n':
5187
+ points = last_points
5188
+ else:
5189
+ points_in = _safe_input(f"Number of points (default {last_points}): ").strip()
5190
+ points = int(points_in) if points_in else last_points
5191
+ else:
5192
+ points_in = _safe_input("Number of points (default 5): ").strip()
5193
+ points = int(points_in) if points_in else 5
5194
+
5195
+ if points < 2:
5196
+ print("Points must be >= 2.")
5197
+ continue
5198
+ push_state("smooth-adjacent-average")
5199
+ _ensure_original_data()
5200
+ processed = 0
5201
+ total_points = 0
5202
+ for i in range(len(x_data_list)):
5203
+ try:
5204
+ # Use current data (may already be processed), not original
5205
+ orig_x = x_data_list[i].copy()
5206
+ orig_y = y_data_list[i].copy()
5207
+ # Remove offset for processing
5208
+ if i < len(offsets_list):
5209
+ orig_y = orig_y - offsets_list[i]
5210
+ n_points = len(orig_y)
5211
+ # Apply smoothing
5212
+ smoothed_y = _adjacent_average_smooth(orig_y, points)
5213
+ if len(smoothed_y) > 0:
5214
+ # Restore offset
5215
+ if i < len(offsets_list):
5216
+ smoothed_y = smoothed_y + offsets_list[i]
5217
+ # Keep original x, update y
5218
+ x_data_list[i] = orig_x.copy()
5219
+ y_data_list[i] = smoothed_y
5220
+ processed += 1
5221
+ total_points += n_points
5222
+ except Exception as e:
5223
+ print(f"Error processing curve {i+1}: {e}")
5224
+ if processed > 0:
5225
+ print(f"Smoothed {processed} curve(s) with {total_points} total points using Adjacent-Averaging (window={points}).")
5226
+ _update_full_processed_data() # Store full processed data for X-range filtering
5227
+ _apply_data_changes()
5228
+ # Store settings (both current and last)
5229
+ if not hasattr(fig, '_smooth_settings'):
5230
+ fig._smooth_settings = {}
5231
+ fig._smooth_settings['method'] = 'adjacent_average'
5232
+ fig._smooth_settings['points'] = points
5233
+ # Store as last settings for next time (both in-memory and config file)
5234
+ if not hasattr(fig, '_last_smooth_settings'):
5235
+ fig._last_smooth_settings = {}
5236
+ fig._last_smooth_settings['method'] = 'adjacent_average'
5237
+ fig._last_smooth_settings['points'] = points
5238
+ # Save to config file for persistence across sessions
5239
+ _save_last_smooth_settings_to_config({
5240
+ 'method': 'adjacent_average',
5241
+ 'points': points
5242
+ })
5243
+ else:
5244
+ print("No curves were smoothed.")
5245
+ except ValueError:
5246
+ print("Invalid number.")
5247
+ continue
5248
+ if method == '2':
5249
+ # Savitzky-Golay
5250
+ try:
5251
+ # Check for last settings (from config file for persistence)
5252
+ config_settings = _get_last_smooth_settings_from_config()
5253
+ session_settings = getattr(fig, '_last_smooth_settings', {})
5254
+ # Prefer config settings (persistent) over session settings
5255
+ last_settings = config_settings if config_settings.get('method') == 'savgol' else session_settings
5256
+ last_method = last_settings.get('method')
5257
+ last_window = last_settings.get('window')
5258
+ last_poly = last_settings.get('poly')
5259
+
5260
+ if last_method == 'savgol' and last_window is not None and last_poly is not None:
5261
+ use_last = _safe_input(f"Use last settings? (window={last_window}, poly={last_poly}, y/n or enter window): ").strip().lower()
5262
+ # Check if user entered a number directly (skip "use last settings")
5263
+ if use_last and use_last.replace('-', '').replace('.', '').isdigit():
5264
+ window = int(float(use_last))
5265
+ if window < 3:
5266
+ window = 3
5267
+ if window % 2 == 0:
5268
+ window += 1
5269
+ poly_in = _safe_input(f"Polynomial order (default {last_poly}): ").strip()
5270
+ poly = int(poly_in) if poly_in else last_poly
5271
+ elif use_last != 'n':
5272
+ window = last_window
5273
+ poly = last_poly
5274
+ else:
5275
+ window_in = _safe_input(f"Window size (odd >= 3, default {last_window}): ").strip()
5276
+ window = int(window_in) if window_in else last_window
5277
+ if window < 3:
5278
+ window = 3
5279
+ if window % 2 == 0:
5280
+ window += 1
5281
+ poly_in = _safe_input(f"Polynomial order (default {last_poly}): ").strip()
5282
+ poly = int(poly_in) if poly_in else last_poly
5283
+ else:
5284
+ window_in = _safe_input("Window size (odd >= 3, default 9): ").strip()
5285
+ window = int(window_in) if window_in else 9
5286
+ if window < 3:
5287
+ window = 3
5288
+ if window % 2 == 0:
5289
+ window += 1
5290
+ poly_in = _safe_input("Polynomial order (default 3): ").strip()
5291
+ poly = int(poly_in) if poly_in else 3
5292
+
5293
+ if poly < 1:
5294
+ poly = 1
5295
+ if poly >= window:
5296
+ poly = window - 1
5297
+ push_state("smooth-savgol")
5298
+ _ensure_original_data()
5299
+ processed = 0
5300
+ total_points = 0
5301
+ for i in range(len(x_data_list)):
5302
+ try:
5303
+ # Use current data (may already be processed), not original
5304
+ orig_x = x_data_list[i].copy()
5305
+ orig_y = y_data_list[i].copy()
5306
+ # Remove offset for processing
5307
+ if i < len(offsets_list):
5308
+ orig_y = orig_y - offsets_list[i]
5309
+ n_points = len(orig_y)
5310
+ # Apply smoothing
5311
+ smoothed_y = _savgol_smooth(orig_y, window, poly)
5312
+ if len(smoothed_y) > 0:
5313
+ # Restore offset
5314
+ if i < len(offsets_list):
5315
+ smoothed_y = smoothed_y + offsets_list[i]
5316
+ # Keep original x, update y
5317
+ x_data_list[i] = orig_x.copy()
5318
+ y_data_list[i] = smoothed_y
5319
+ processed += 1
5320
+ total_points += n_points
5321
+ except Exception as e:
5322
+ print(f"Error processing curve {i+1}: {e}")
5323
+ if processed > 0:
5324
+ print(f"Smoothed {processed} curve(s) with {total_points} total points using Savitzky-Golay (window={window}, poly={poly}).")
5325
+ _update_full_processed_data() # Store full processed data for X-range filtering
5326
+ _apply_data_changes()
5327
+ # Store settings (both current and last)
5328
+ if not hasattr(fig, '_smooth_settings'):
5329
+ fig._smooth_settings = {}
5330
+ fig._smooth_settings['method'] = 'savgol'
5331
+ fig._smooth_settings['window'] = window
5332
+ fig._smooth_settings['poly'] = poly
5333
+ # Store as last settings for next time (both in-memory and config file)
5334
+ if not hasattr(fig, '_last_smooth_settings'):
5335
+ fig._last_smooth_settings = {}
5336
+ fig._last_smooth_settings['method'] = 'savgol'
5337
+ fig._last_smooth_settings['window'] = window
5338
+ fig._last_smooth_settings['poly'] = poly
5339
+ # Save to config file for persistence across sessions
5340
+ _save_last_smooth_settings_to_config({
5341
+ 'method': 'savgol',
5342
+ 'window': window,
5343
+ 'poly': poly
5344
+ })
5345
+ else:
5346
+ print("No curves were smoothed.")
5347
+ except ValueError:
5348
+ print("Invalid number.")
5349
+ continue
5350
+ if method == '3':
5351
+ # FFT Filter
5352
+ try:
5353
+ # Check for last settings (from config file for persistence)
5354
+ config_settings = _get_last_smooth_settings_from_config()
5355
+ session_settings = getattr(fig, '_last_smooth_settings', {})
5356
+ # Prefer config settings (persistent) over session settings
5357
+ last_settings = config_settings if config_settings.get('method') == 'fft' else session_settings
5358
+ last_method = last_settings.get('method')
5359
+ last_points = last_settings.get('points')
5360
+ last_cutoff = last_settings.get('cutoff')
5361
+
5362
+ if last_method == 'fft' and last_points is not None and last_cutoff is not None:
5363
+ use_last = _safe_input(f"Use last settings? (points={last_points}, cutoff={last_cutoff:.3f}, y/n or enter points): ").strip().lower()
5364
+ # Check if user entered a number directly (skip "use last settings")
5365
+ if use_last and use_last.replace('-', '').replace('.', '').isdigit():
5366
+ points = int(float(use_last))
5367
+ if points < 2:
5368
+ points = 2
5369
+ cutoff_in = _safe_input(f"Cutoff frequency (0-1, default {last_cutoff:.3f}): ").strip()
5370
+ cutoff = float(cutoff_in) if cutoff_in else last_cutoff
5371
+ elif use_last != 'n':
5372
+ points = last_points
5373
+ cutoff = last_cutoff
5374
+ else:
5375
+ points_in = _safe_input(f"Points for FFT (default {last_points}): ").strip()
5376
+ points = int(points_in) if points_in else last_points
5377
+ if points < 2:
5378
+ points = 2
5379
+ cutoff_in = _safe_input(f"Cutoff frequency (0-1, default {last_cutoff:.3f}): ").strip()
5380
+ cutoff = float(cutoff_in) if cutoff_in else last_cutoff
5381
+ else:
5382
+ points_in = _safe_input("Points for FFT (default 5): ").strip()
5383
+ points = int(points_in) if points_in else 5
5384
+ if points < 2:
5385
+ points = 2
5386
+ cutoff_in = _safe_input("Cutoff frequency (0-1, default 0.1): ").strip()
5387
+ cutoff = float(cutoff_in) if cutoff_in else 0.1
5388
+
5389
+ if cutoff < 0:
5390
+ cutoff = 0
5391
+ if cutoff > 1:
5392
+ cutoff = 1
5393
+ push_state("smooth-fft")
5394
+ _ensure_original_data()
5395
+ processed = 0
5396
+ total_points = 0
5397
+ for i in range(len(x_data_list)):
5398
+ try:
5399
+ # Use current data (may already be processed), not original
5400
+ orig_x = x_data_list[i].copy()
5401
+ orig_y = y_data_list[i].copy()
5402
+ # Remove offset for processing
5403
+ if i < len(offsets_list):
5404
+ orig_y = orig_y - offsets_list[i]
5405
+ n_points = len(orig_y)
5406
+ # Apply smoothing
5407
+ smoothed_y = _fft_smooth(orig_y, points, cutoff)
5408
+ if len(smoothed_y) > 0:
5409
+ # Restore offset
5410
+ if i < len(offsets_list):
5411
+ smoothed_y = smoothed_y + offsets_list[i]
5412
+ # Keep original x, update y
5413
+ x_data_list[i] = orig_x.copy()
5414
+ y_data_list[i] = smoothed_y
5415
+ processed += 1
5416
+ total_points += n_points
5417
+ except Exception as e:
5418
+ print(f"Error processing curve {i+1}: {e}")
5419
+ if processed > 0:
5420
+ print(f"Smoothed {processed} curve(s) with {total_points} total points using FFT Filter (cutoff={cutoff:.3f}).")
5421
+ _update_full_processed_data() # Store full processed data for X-range filtering
5422
+ _apply_data_changes()
5423
+ # Store settings (both current and last)
5424
+ if not hasattr(fig, '_smooth_settings'):
5425
+ fig._smooth_settings = {}
5426
+ fig._smooth_settings['method'] = 'fft'
5427
+ fig._smooth_settings['points'] = points
5428
+ fig._smooth_settings['cutoff'] = cutoff
5429
+ # Store as last settings for next time (both in-memory and config file)
5430
+ if not hasattr(fig, '_last_smooth_settings'):
5431
+ fig._last_smooth_settings = {}
5432
+ fig._last_smooth_settings['method'] = 'fft'
5433
+ fig._last_smooth_settings['points'] = points
5434
+ fig._last_smooth_settings['cutoff'] = cutoff
5435
+ # Save to config file for persistence across sessions
5436
+ _save_last_smooth_settings_to_config({
5437
+ 'method': 'fft',
5438
+ 'points': points,
5439
+ 'cutoff': cutoff
5440
+ })
5441
+ else:
5442
+ print("No curves were smoothed.")
5443
+ except ValueError:
5444
+ print("Invalid number.")
5445
+ continue
3800
5446
  elif key == 'v':
3801
5447
  while True:
3802
5448
  try:
@@ -3836,6 +5482,7 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3836
5482
 
3837
5483
  print("\n--- Peak Report ---")
3838
5484
  print(f"X range used: {x_min} .. {x_max} (relative height threshold={min_frac})")
5485
+ all_peak_results = [] # list of (curve_index, label, [(x, y), ...])
3839
5486
  for i, (x_arr, y_off) in enumerate(zip(x_data_list, y_data_list)):
3840
5487
  # Recover original curve (remove vertical offset)
3841
5488
  if i < len(offsets_list):
@@ -3889,9 +5536,50 @@ def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
3889
5536
  last_idx = pi
3890
5537
 
3891
5538
  print(" Peaks (x, y):")
5539
+ peak_xy_list = []
3892
5540
  for pi in peaks:
5541
+ px, py = float(x_sel[pi]), float(y_sel[pi])
5542
+ peak_xy_list.append((px, py))
3893
5543
  print(f" x={x_sel[pi]:.6g}, y={y_sel[pi]:.6g}")
5544
+ if peak_xy_list:
5545
+ all_peak_results.append((i + 1, label, peak_xy_list))
3894
5546
  print("\n--- End Peak Report ---\n")
5547
+
5548
+ # Export peaks to file
5549
+ if all_peak_results:
5550
+ export_yn = _safe_input("Export peaks to file? (y/n): ").strip().lower()
5551
+ if export_yn == 'y':
5552
+ folder = choose_save_path(source_file_paths, purpose="peak export")
5553
+ if folder:
5554
+ print(f"\nChosen path: {folder}")
5555
+ fname = _safe_input("Export filename (default: peaks.txt): ").strip()
5556
+ if not fname:
5557
+ fname = "peaks.txt"
5558
+ if not fname.endswith('.txt'):
5559
+ fname += '.txt'
5560
+ import os
5561
+ target = fname if os.path.isabs(fname) else os.path.join(folder, fname)
5562
+ do_write = not os.path.exists(target)
5563
+ if os.path.exists(target):
5564
+ ow = _safe_input(f"'{os.path.basename(target)}' exists. Overwrite? (y/n): ").strip().lower()
5565
+ if ow == 'y':
5566
+ do_write = True
5567
+ else:
5568
+ print("Export canceled.")
5569
+ if do_write:
5570
+ try:
5571
+ with open(target, 'w') as f:
5572
+ f.write("# Curve\tLabel\tPeak x\tPeak y\n")
5573
+ for curve_idx, label, peak_xy_list in all_peak_results:
5574
+ for px, py in peak_xy_list:
5575
+ f.write(f"{curve_idx}\t{label}\t{px:.6g}\t{py:.6g}\n")
5576
+ total_peaks = sum(len(pairs) for _, _, pairs in all_peak_results)
5577
+ print(f"Peak positions exported to {target}")
5578
+ print(f"Found {total_peaks} peaks across {len(all_peak_results)} curves.")
5579
+ except Exception as e:
5580
+ print(f"Error saving file: {e}")
5581
+ else:
5582
+ print("Export canceled.")
3895
5583
  except Exception as e:
3896
5584
  print(f"Error finding peaks: {e}")
3897
5585