masster 0.3.18__py3-none-any.whl → 0.3.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of masster might be problematic. Click here for more details.

Files changed (31) hide show
  1. masster/__init__.py +2 -0
  2. masster/_version.py +1 -1
  3. masster/data/libs/README.md +17 -0
  4. masster/data/libs/ccm.py +533 -0
  5. masster/data/libs/central_carbon_README.md +17 -0
  6. masster/data/libs/central_carbon_metabolites.csv +120 -0
  7. masster/data/libs/urine.py +333 -0
  8. masster/data/libs/urine_metabolites.csv +51 -0
  9. masster/sample/h5.py +1 -1
  10. masster/sample/helpers.py +3 -7
  11. masster/sample/lib.py +32 -25
  12. masster/sample/load.py +9 -3
  13. masster/sample/plot.py +113 -27
  14. masster/study/export.py +27 -10
  15. masster/study/h5.py +58 -40
  16. masster/study/helpers.py +450 -196
  17. masster/study/helpers_optimized.py +5 -5
  18. masster/study/load.py +144 -118
  19. masster/study/plot.py +691 -277
  20. masster/study/processing.py +9 -5
  21. masster/study/study.py +6 -6
  22. {masster-0.3.18.dist-info → masster-0.3.20.dist-info}/METADATA +1 -1
  23. {masster-0.3.18.dist-info → masster-0.3.20.dist-info}/RECORD +31 -25
  24. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.mzML +0 -0
  25. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.timeseries.data +0 -0
  26. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff +0 -0
  27. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff.scan +0 -0
  28. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff2 +0 -0
  29. {masster-0.3.18.dist-info → masster-0.3.20.dist-info}/WHEEL +0 -0
  30. {masster-0.3.18.dist-info → masster-0.3.20.dist-info}/entry_points.txt +0 -0
  31. {masster-0.3.18.dist-info → masster-0.3.20.dist-info}/licenses/LICENSE +0 -0
masster/study/plot.py CHANGED
@@ -17,7 +17,222 @@ hv.extension("bokeh")
17
17
  from bokeh.layouts import row as bokeh_row
18
18
 
19
19
 
20
- def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | None = None, filename: str | None = None, width: int = 450, height: int = 450, markersize: int = 3):
20
+ def _isolated_save_plot(plot_object, filename, abs_filename, logger, plot_title="Plot"):
21
+ """
22
+ Save a plot using isolated file saving that doesn't affect global Bokeh state.
23
+ This prevents browser opening issues when mixing file and notebook outputs.
24
+ """
25
+ if filename.endswith(".html"):
26
+ # Use isolated file saving that doesn't affect global output state
27
+ from bokeh.resources import Resources
28
+ from bokeh.embed import file_html
29
+
30
+ # Create HTML content without affecting global state
31
+ resources = Resources(mode='cdn')
32
+ html = file_html(plot_object, resources, title=plot_title)
33
+
34
+ # Write directly to file
35
+ with open(filename, 'w', encoding='utf-8') as f:
36
+ f.write(html)
37
+
38
+ logger.info(f"Plot saved to: {abs_filename}")
39
+
40
+ elif filename.endswith(".png"):
41
+ try:
42
+ from bokeh.io.export import export_png
43
+ export_png(plot_object, filename=filename)
44
+ logger.info(f"Plot saved to: {abs_filename}")
45
+ except Exception:
46
+ # Fall back to HTML if PNG export not available
47
+ html_filename = filename.replace('.png', '.html')
48
+ from bokeh.resources import Resources
49
+ from bokeh.embed import file_html
50
+
51
+ resources = Resources(mode='cdn')
52
+ html = file_html(plot_object, resources, title=plot_title)
53
+
54
+ with open(html_filename, 'w', encoding='utf-8') as f:
55
+ f.write(html)
56
+
57
+ logger.warning(f"PNG export not available, saved as HTML instead: {html_filename}")
58
+ elif filename.endswith(".pdf"):
59
+ # Try to save as PDF, fall back to HTML if not available
60
+ try:
61
+ from bokeh.io.export import export_pdf
62
+ export_pdf(plot_object, filename=filename)
63
+ logger.info(f"Plot saved to: {abs_filename}")
64
+ except ImportError:
65
+ # Fall back to HTML if PDF export not available
66
+ html_filename = filename.replace('.pdf', '.html')
67
+ from bokeh.resources import Resources
68
+ from bokeh.embed import file_html
69
+
70
+ resources = Resources(mode='cdn')
71
+ html = file_html(plot_object, resources, title=plot_title)
72
+
73
+ with open(html_filename, 'w', encoding='utf-8') as f:
74
+ f.write(html)
75
+
76
+ logger.warning(f"PDF export not available, saved as HTML instead: {html_filename}")
77
+ else:
78
+ # Default to HTML for unknown extensions using isolated approach
79
+ from bokeh.resources import Resources
80
+ from bokeh.embed import file_html
81
+
82
+ resources = Resources(mode='cdn')
83
+ html = file_html(plot_object, resources, title=plot_title)
84
+
85
+ with open(filename, 'w', encoding='utf-8') as f:
86
+ f.write(html)
87
+
88
+ logger.info(f"Plot saved to: {abs_filename}")
89
+
90
+
91
+ def _isolated_show_notebook(plot_object):
92
+ """
93
+ Show a plot in notebook using isolated display that resets Bokeh state.
94
+ This prevents browser opening issues when mixing file and notebook outputs.
95
+ """
96
+ from bokeh.io import reset_output, output_notebook, show
97
+ import holoviews as hv
98
+ import warnings
99
+ import logging
100
+
101
+ # Suppress both warnings and logging messages for the specific Bokeh callback warnings
102
+ # that occur when Panel components with Python callbacks are converted to standalone Bokeh
103
+ bokeh_logger = logging.getLogger('bokeh.embed.util')
104
+ original_level = bokeh_logger.level
105
+ bokeh_logger.setLevel(logging.ERROR) # Suppress WARNING level messages
106
+
107
+ with warnings.catch_warnings():
108
+ warnings.filterwarnings("ignore", message=".*standalone HTML/JS output.*", category=UserWarning)
109
+ warnings.filterwarnings("ignore", message=".*real Python callbacks.*", category=UserWarning)
110
+
111
+ try:
112
+ # First clear all output state
113
+ reset_output()
114
+
115
+ # Set notebook mode
116
+ output_notebook(hide_banner=True)
117
+
118
+ # Reset Holoviews to notebook mode
119
+ hv.extension('bokeh', logo=False)
120
+ hv.output(backend='bokeh', mode='jupyter')
121
+
122
+ # Show in notebook
123
+ show(plot_object)
124
+ finally:
125
+ # Restore original logging level
126
+ bokeh_logger.setLevel(original_level)
127
+
128
+
129
+ def _isolated_save_panel_plot(panel_obj, filename, abs_filename, logger, plot_title):
130
+ """
131
+ Save a Panel plot using isolated approach that doesn't affect global Bokeh state.
132
+
133
+ Args:
134
+ panel_obj: Panel object to save
135
+ filename: Target filename
136
+ abs_filename: Absolute path for logging
137
+ logger: Logger instance
138
+ plot_title: Title for logging
139
+ """
140
+ import os # Import os for path operations
141
+
142
+ if filename.endswith(".html"):
143
+ # Panel save method should be isolated but let's be sure
144
+ try:
145
+ # Save directly without affecting global Bokeh state
146
+ panel_obj.save(filename, embed=True)
147
+ logger.info(f"{plot_title} saved to: {abs_filename}")
148
+ except Exception as e:
149
+ logger.error(f"Failed to save {plot_title}: {e}")
150
+
151
+ elif filename.endswith(".png"):
152
+ try:
153
+ from panel.io.save import save_png
154
+ # Convert Panel to Bokeh models before saving
155
+ bokeh_layout = panel_obj.get_root()
156
+ save_png(bokeh_layout, filename=filename)
157
+ logger.info(f"{plot_title} saved to: {abs_filename}")
158
+ except Exception:
159
+ # Fall back to HTML if PNG export not available
160
+ html_filename = filename.replace('.png', '.html')
161
+ abs_html_filename = os.path.abspath(html_filename)
162
+ try:
163
+ panel_obj.save(html_filename, embed=True)
164
+ logger.warning(f"PNG export not available, saved as HTML instead: {abs_html_filename}")
165
+ except Exception as e:
166
+ logger.error(f"Failed to save {plot_title} as HTML fallback: {e}")
167
+
168
+ elif filename.endswith(".pdf"):
169
+ # Try to save as PDF, fall back to HTML if not available
170
+ try:
171
+ from bokeh.io.export import export_pdf
172
+ bokeh_layout = panel_obj.get_root()
173
+ export_pdf(bokeh_layout, filename=filename)
174
+ logger.info(f"{plot_title} saved to: {abs_filename}")
175
+ except ImportError:
176
+ # Fall back to HTML if PDF export not available
177
+ html_filename = filename.replace('.pdf', '.html')
178
+ abs_html_filename = os.path.abspath(html_filename)
179
+ try:
180
+ panel_obj.save(html_filename, embed=True)
181
+ logger.warning(f"PDF export not available, saved as HTML instead: {abs_html_filename}")
182
+ except Exception as e:
183
+ logger.error(f"Failed to save {plot_title} as HTML fallback: {e}")
184
+ else:
185
+ # Default to HTML for unknown extensions
186
+ try:
187
+ panel_obj.save(filename, embed=True)
188
+ logger.info(f"{plot_title} saved to: {abs_filename}")
189
+ except Exception as e:
190
+ logger.error(f"Failed to save {plot_title}: {e}")
191
+
192
+
193
+ def _isolated_show_panel_notebook(panel_obj):
194
+ """
195
+ Show a Panel plot in notebook with state isolation to prevent browser opening.
196
+
197
+ Args:
198
+ panel_obj: Panel object to display
199
+ """
200
+ # Reset Bokeh state completely to prevent browser opening if output_file was called before
201
+ from bokeh.io import reset_output, output_notebook
202
+ import holoviews as hv
203
+
204
+ # First clear all output state
205
+ reset_output()
206
+
207
+ # Set notebook mode
208
+ output_notebook(hide_banner=True)
209
+
210
+ # Reset Holoviews to notebook mode
211
+ hv.extension('bokeh', logo=False)
212
+ hv.output(backend='bokeh', mode='jupyter')
213
+
214
+ # For Panel objects in notebooks, use pn.extension and display inline
215
+ import panel as pn
216
+ try:
217
+ # Configure Panel for notebook display
218
+ pn.extension('bokeh', inline=True, comms='vscode')
219
+ # Use IPython display to show inline instead of show()
220
+ from IPython.display import display
221
+ display(panel_obj)
222
+ except Exception:
223
+ # Fallback to regular Panel show
224
+ panel_obj.show()
225
+
226
+
227
+ def plot_alignment(
228
+ self,
229
+ samples=None,
230
+ maps: bool = True,
231
+ filename: str | None = None,
232
+ width: int = 450,
233
+ height: int = 450,
234
+ markersize: int = 3,
235
+ ):
21
236
  """Visualize retention time alignment using two synchronized Bokeh scatter plots.
22
237
 
23
238
  - When ``maps=True`` the function reads ``self.features_maps`` (list of FeatureMap)
@@ -26,12 +241,8 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
26
241
  ``rt_original`` column (before) and ``rt`` column (after).
27
242
 
28
243
  Parameters
244
+ - samples: List of sample identifiers (sample_uids or sample_names), or single int for random selection, or None for all samples.
29
245
  - maps: whether to use feature maps (default True).
30
- - samples: Sample selection parameter, interpreted like in plot_samples_2d:
31
- - None: show all samples
32
- - int: show a random subset of N samples
33
- - list of ints: show samples with these sample_uids
34
- - list of strings: show samples with these sample_names
35
246
  - filename: optional HTML file path to save the plot.
36
247
  - width/height: pixel size of each subplot.
37
248
  - markersize: base marker size.
@@ -44,6 +255,9 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
44
255
  from bokeh.plotting import figure, show, output_file
45
256
  import pandas as pd
46
257
 
258
+ # Get sample_uids to filter by if specified
259
+ sample_uids = self._get_sample_uids(samples) if samples is not None else None
260
+
47
261
  # Build the before/after tabular data used for plotting
48
262
  before_data: list[dict[str, Any]] = []
49
263
  after_data: list[dict[str, Any]] = []
@@ -59,31 +273,39 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
59
273
  self.logger.error("No feature maps available for plotting.")
60
274
  return
61
275
 
62
- # Get sample_uids to limit which samples to show
63
- sample_uids_to_show = self._get_sample_uids(samples)
64
-
65
- # Filter feature maps based on sample selection
66
- if sample_uids_to_show is not None:
67
- # Get sample indices for the selected sample_uids
68
- selected_indices = []
69
- if hasattr(self, 'samples_df') and self.samples_df is not None and not self.samples_df.is_empty():
276
+ # Filter feature maps by selected samples if specified
277
+ if sample_uids is not None:
278
+ # Create mapping from sample_uid to map_id and filter accordingly
279
+ if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
70
280
  samples_info = self.samples_df.to_pandas()
71
- for idx, row in samples_info.iterrows():
72
- if row.get('sample_uid') in sample_uids_to_show:
73
- selected_indices.append(idx)
281
+
282
+ # Filter samples_info to only selected sample_uids and get their map_ids
283
+ selected_samples = samples_info[samples_info["sample_uid"].isin(sample_uids)]
284
+ if selected_samples.empty:
285
+ self.logger.error("No matching samples found for the provided sample_uids.")
286
+ return
287
+
288
+ # Get the map_ids for selected samples
289
+ selected_map_ids = selected_samples["map_id"].tolist()
290
+
291
+ # Filter feature maps based on map_ids
292
+ filtered_maps = []
293
+ for map_id in selected_map_ids:
294
+ if 0 <= map_id < len(fmaps):
295
+ filtered_maps.append(fmaps[map_id])
296
+
297
+ fmaps = filtered_maps
298
+ samples_info = selected_samples.reset_index(drop=True)
299
+
300
+ if not fmaps:
301
+ self.logger.error("No feature maps found for the selected samples.")
302
+ return
74
303
  else:
75
- # If no samples_df, just limit to the first N samples
76
- if isinstance(samples, int):
77
- selected_indices = list(range(min(samples, len(fmaps))))
78
- else:
79
- selected_indices = list(range(len(fmaps)))
80
-
81
- # Filter feature maps to only include selected indices
82
- fmaps = [fmaps[i] for i in selected_indices if i < len(fmaps)]
83
-
84
- if not fmaps:
85
- self.logger.error("No feature maps match the selected samples.")
86
- return
304
+ self.logger.warning("Cannot filter feature maps: no samples_df available")
305
+
306
+ if not fmaps:
307
+ self.logger.error("No feature maps available after filtering.")
308
+ return
87
309
 
88
310
  # Reference (first) sample: use current RT for both before and after
89
311
  ref = fmaps[0]
@@ -92,20 +314,43 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
92
314
  ref_inty = [f.getIntensity() for f in ref]
93
315
  max_ref_inty = max(ref_inty) if ref_inty else 1
94
316
 
95
- # sample metadata
96
- if hasattr(self, 'samples_df') and self.samples_df is not None and not self.samples_df.is_empty():
97
- samples_info = self.samples_df.to_pandas()
98
- ref_sample_uid = samples_info.iloc[0]['sample_uid'] if 'sample_uid' in samples_info.columns else 'Reference_UID'
99
- ref_sample_name = samples_info.iloc[0]['sample_name'] if 'sample_name' in samples_info.columns else 'Reference'
317
+ # Get sample metadata for reference (first) sample
318
+ if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
319
+ if 'samples_info' not in locals():
320
+ samples_info = self.samples_df.to_pandas()
321
+ ref_sample_uid = (
322
+ samples_info.iloc[0]["sample_uid"] if "sample_uid" in samples_info.columns else "Reference_UID"
323
+ )
324
+ ref_sample_name = (
325
+ samples_info.iloc[0]["sample_name"] if "sample_name" in samples_info.columns else "Reference"
326
+ )
100
327
  else:
101
- ref_sample_uid = 'Reference_UID'
102
- ref_sample_name = 'Reference'
328
+ ref_sample_uid = "Reference_UID"
329
+ ref_sample_name = "Reference"
103
330
 
104
331
  for rt, mz, inty in zip(ref_rt, ref_mz, ref_inty):
105
- before_data.append({'rt': rt, 'mz': mz, 'inty': inty, 'alpha': inty / max_ref_inty, 'sample_idx': 0, 'sample_name': ref_sample_name, 'sample_uid': ref_sample_uid, 'size': markersize + 2})
106
- after_data.append({'rt': rt, 'mz': mz, 'inty': inty, 'alpha': inty / max_ref_inty, 'sample_idx': 0, 'sample_name': ref_sample_name, 'sample_uid': ref_sample_uid, 'size': markersize + 2})
107
-
108
- # Remaining samples
332
+ before_data.append({
333
+ "rt": rt,
334
+ "mz": mz,
335
+ "inty": inty,
336
+ "alpha": inty / max_ref_inty,
337
+ "sample_idx": 0,
338
+ "sample_name": ref_sample_name,
339
+ "sample_uid": ref_sample_uid,
340
+ "size": markersize + 2,
341
+ })
342
+ after_data.append({
343
+ "rt": rt,
344
+ "mz": mz,
345
+ "inty": inty,
346
+ "alpha": inty / max_ref_inty,
347
+ "sample_idx": 0,
348
+ "sample_name": ref_sample_name,
349
+ "sample_uid": ref_sample_uid,
350
+ "size": markersize + 2,
351
+ })
352
+
353
+ # Remaining samples - now using filtered feature maps and samples_info
109
354
  for sample_idx, fm in enumerate(fmaps[1:], start=1):
110
355
  mz_vals = []
111
356
  inty_vals = []
@@ -114,7 +359,7 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
114
359
 
115
360
  for f in fm:
116
361
  try:
117
- orig = f.getMetaValue('original_RT')
362
+ orig = f.getMetaValue("original_RT")
118
363
  except Exception:
119
364
  orig = None
120
365
 
@@ -132,23 +377,48 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
132
377
 
133
378
  max_inty = max(inty_vals)
134
379
 
135
- if hasattr(self, 'samples_df') and self.samples_df is not None and not self.samples_df.is_empty():
136
- samples_info = self.samples_df.to_pandas()
137
- if sample_idx < len(samples_info):
138
- sample_name = samples_info.iloc[sample_idx].get('sample_name', f'Sample {sample_idx}')
139
- sample_uid = samples_info.iloc[sample_idx].get('sample_uid', f'Sample_{sample_idx}_UID')
380
+ # Get sample metadata from filtered samples_info
381
+ if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
382
+ # Use filtered samples_info if it exists from the filtering above
383
+ if 'samples_info' in locals() and sample_idx < len(samples_info):
384
+ sample_name = samples_info.iloc[sample_idx].get("sample_name", f"Sample {sample_idx}")
385
+ sample_uid = samples_info.iloc[sample_idx].get("sample_uid", f"Sample_{sample_idx}_UID")
140
386
  else:
141
- sample_name = f'Sample {sample_idx}'
142
- sample_uid = f'Sample_{sample_idx}_UID'
387
+ # Fallback to original samples_df if filtered samples_info is not available
388
+ all_samples_info = self.samples_df.to_pandas()
389
+ if sample_idx < len(all_samples_info):
390
+ sample_name = all_samples_info.iloc[sample_idx].get("sample_name", f"Sample {sample_idx}")
391
+ sample_uid = all_samples_info.iloc[sample_idx].get("sample_uid", f"Sample_{sample_idx}_UID")
392
+ else:
393
+ sample_name = f"Sample {sample_idx}"
394
+ sample_uid = f"Sample_{sample_idx}_UID"
143
395
  else:
144
- sample_name = f'Sample {sample_idx}'
145
- sample_uid = f'Sample_{sample_idx}_UID'
396
+ sample_name = f"Sample {sample_idx}"
397
+ sample_uid = f"Sample_{sample_idx}_UID"
146
398
 
147
399
  for rt, mz, inty in zip(original_rt, mz_vals, inty_vals):
148
- before_data.append({'rt': rt, 'mz': mz, 'inty': inty, 'alpha': inty / max_inty, 'sample_idx': sample_idx, 'sample_name': sample_name, 'sample_uid': sample_uid, 'size': markersize})
400
+ before_data.append({
401
+ "rt": rt,
402
+ "mz": mz,
403
+ "inty": inty,
404
+ "alpha": inty / max_inty,
405
+ "sample_idx": sample_idx,
406
+ "sample_name": sample_name,
407
+ "sample_uid": sample_uid,
408
+ "size": markersize,
409
+ })
149
410
 
150
411
  for rt, mz, inty in zip(aligned_rt, mz_vals, inty_vals):
151
- after_data.append({'rt': rt, 'mz': mz, 'inty': inty, 'alpha': inty / max_inty, 'sample_idx': sample_idx, 'sample_name': sample_name, 'sample_uid': sample_uid, 'size': markersize})
412
+ after_data.append({
413
+ "rt": rt,
414
+ "mz": mz,
415
+ "inty": inty,
416
+ "alpha": inty / max_inty,
417
+ "sample_idx": sample_idx,
418
+ "sample_name": sample_name,
419
+ "sample_uid": sample_uid,
420
+ "size": markersize,
421
+ })
152
422
 
153
423
  else:
154
424
  # Use features_df
@@ -156,88 +426,83 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
156
426
  self.logger.error("No features_df found. Load features first.")
157
427
  return
158
428
 
159
- required_cols = ['rt', 'mz', 'inty']
429
+ required_cols = ["rt", "mz", "inty"]
160
430
  missing = [c for c in required_cols if c not in self.features_df.columns]
161
431
  if missing:
162
432
  self.logger.error(f"Missing required columns in features_df: {missing}")
163
433
  return
164
434
 
165
- if 'rt_original' not in self.features_df.columns:
435
+ if "rt_original" not in self.features_df.columns:
166
436
  self.logger.error("Column 'rt_original' not found in features_df. Alignment may not have been performed.")
167
437
  return
168
438
 
169
439
  # Use Polars instead of pandas
170
440
  features_df = self.features_df
441
+
442
+ # Filter by selected samples if specified
443
+ if sample_uids is not None:
444
+ features_df = features_df.filter(pl.col("sample_uid").is_in(sample_uids))
445
+ if features_df.is_empty():
446
+ self.logger.error("No features found for the selected samples.")
447
+ return
171
448
 
172
- sample_col = 'sample_uid' if 'sample_uid' in features_df.columns else 'sample_name'
449
+ sample_col = "sample_uid" if "sample_uid" in features_df.columns else "sample_name"
173
450
  if sample_col not in features_df.columns:
174
451
  self.logger.error("No sample identifier column found in features_df.")
175
452
  return
176
453
 
177
- # Get sample_uids to limit which samples to show
178
- sample_uids_to_show = self._get_sample_uids(samples)
179
-
180
- # Filter features_df based on sample selection if specified
181
- if sample_uids_to_show is not None:
182
- if sample_col == 'sample_uid':
183
- features_df = features_df.filter(pl.col('sample_uid').is_in(sample_uids_to_show))
184
- else:
185
- # Need to convert sample names to sample_uids if using sample_name column
186
- if 'sample_uid' in features_df.columns:
187
- # Filter by sample_uid even though we're using sample_name as the primary column
188
- features_df = features_df.filter(pl.col('sample_uid').is_in(sample_uids_to_show))
189
- else:
190
- # Convert sample_uids to sample_names and filter
191
- sample_names_to_show = []
192
- if hasattr(self, 'samples_df') and self.samples_df is not None:
193
- for uid in sample_uids_to_show:
194
- matching_rows = self.samples_df.filter(pl.col("sample_uid") == uid)
195
- if not matching_rows.is_empty():
196
- sample_names_to_show.append(matching_rows.row(0, named=True)["sample_name"])
197
- features_df = features_df.filter(pl.col('sample_name').is_in(sample_names_to_show))
198
-
199
454
  # Get unique samples using Polars
200
455
  samples = features_df.select(pl.col(sample_col)).unique().to_series().to_list()
201
456
 
202
457
  for sample_idx, sample in enumerate(samples):
203
458
  # Filter sample data using Polars
204
459
  sample_data = features_df.filter(pl.col(sample_col) == sample)
205
-
460
+
206
461
  # Calculate max intensity using Polars
207
- max_inty = sample_data.select(pl.col('inty').max()).item()
462
+ max_inty = sample_data.select(pl.col("inty").max()).item()
208
463
  max_inty = max_inty if max_inty and max_inty > 0 else 1
209
-
464
+
210
465
  sample_name = str(sample)
211
466
  # Get sample_uid - if sample_col is 'sample_uid', use sample directly
212
- if sample_col == 'sample_uid':
467
+ if sample_col == "sample_uid":
213
468
  sample_uid = sample
214
469
  else:
215
470
  # Try to get sample_uid from the first row if it exists
216
- if 'sample_uid' in sample_data.columns:
217
- sample_uid = sample_data.select(pl.col('sample_uid')).item()
471
+ if "sample_uid" in sample_data.columns:
472
+ sample_uid = sample_data.select(pl.col("sample_uid")).item()
218
473
  else:
219
474
  sample_uid = sample
220
475
 
221
476
  # Convert to dict for iteration - more efficient than row-by-row processing
222
- sample_dict = sample_data.select(['rt_original', 'rt', 'mz', 'inty']).to_dicts()
223
-
477
+ sample_dict = sample_data.select(["rt_original", "rt", "mz", "inty"]).to_dicts()
478
+
224
479
  for row_dict in sample_dict:
225
- rt_original = row_dict['rt_original']
226
- rt_current = row_dict['rt']
227
- mz = row_dict['mz']
228
- inty = row_dict['inty']
480
+ rt_original = row_dict["rt_original"]
481
+ rt_current = row_dict["rt"]
482
+ mz = row_dict["mz"]
483
+ inty = row_dict["inty"]
229
484
  alpha = inty / max_inty
230
485
  size = markersize + 2 if sample_idx == 0 else markersize
231
-
486
+
232
487
  before_data.append({
233
- 'rt': rt_original, 'mz': mz, 'inty': inty, 'alpha': alpha,
234
- 'sample_idx': sample_idx, 'sample_name': sample_name,
235
- 'sample_uid': sample_uid, 'size': size
488
+ "rt": rt_original,
489
+ "mz": mz,
490
+ "inty": inty,
491
+ "alpha": alpha,
492
+ "sample_idx": sample_idx,
493
+ "sample_name": sample_name,
494
+ "sample_uid": sample_uid,
495
+ "size": size,
236
496
  })
237
497
  after_data.append({
238
- 'rt': rt_current, 'mz': mz, 'inty': inty, 'alpha': alpha,
239
- 'sample_idx': sample_idx, 'sample_name': sample_name,
240
- 'sample_uid': sample_uid, 'size': size
498
+ "rt": rt_current,
499
+ "mz": mz,
500
+ "inty": inty,
501
+ "alpha": alpha,
502
+ "sample_idx": sample_idx,
503
+ "sample_name": sample_name,
504
+ "sample_uid": sample_uid,
505
+ "size": size,
241
506
  })
242
507
 
243
508
  # Get sample colors from samples_df using sample indices
@@ -246,17 +511,16 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
246
511
  # Create mapping from sample_idx to sample_uid more efficiently
247
512
  sample_idx_to_uid = {}
248
513
  for item in before_data:
249
- if item['sample_idx'] not in sample_idx_to_uid:
250
- sample_idx_to_uid[item['sample_idx']] = item['sample_uid']
514
+ if item["sample_idx"] not in sample_idx_to_uid:
515
+ sample_idx_to_uid[item["sample_idx"]] = item["sample_uid"]
251
516
  else:
252
517
  sample_idx_to_uid = {}
253
-
518
+
254
519
  # Get colors from samples_df
255
520
  sample_uids_list = list(sample_idx_to_uid.values())
256
- if sample_uids_list and hasattr(self, 'samples_df') and self.samples_df is not None:
521
+ if sample_uids_list and hasattr(self, "samples_df") and self.samples_df is not None:
257
522
  sample_colors = (
258
- self.samples_df
259
- .filter(pl.col("sample_uid").is_in(sample_uids_list))
523
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids_list))
260
524
  .select(["sample_uid", "sample_color"])
261
525
  .to_dict(as_series=False)
262
526
  )
@@ -272,69 +536,112 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
272
536
  # Add sample_color to data dictionaries before creating DataFrames
273
537
  if before_data:
274
538
  for item in before_data:
275
- item['sample_color'] = color_map.get(item['sample_idx'], '#1f77b4')
276
-
539
+ item["sample_color"] = color_map.get(item["sample_idx"], "#1f77b4")
540
+
277
541
  if after_data:
278
542
  for item in after_data:
279
- item['sample_color'] = color_map.get(item['sample_idx'], '#1f77b4')
280
-
543
+ item["sample_color"] = color_map.get(item["sample_idx"], "#1f77b4")
544
+
281
545
  # Now create DataFrames with the sample_color already included
282
546
  before_df = pd.DataFrame(before_data) if before_data else pd.DataFrame()
283
547
  after_df = pd.DataFrame(after_data) if after_data else pd.DataFrame()
284
548
 
285
549
  # Create Bokeh figures
286
- p1 = figure(width=width, height=height, title='Original RT', x_axis_label='Retention Time (s)', y_axis_label='m/z', tools='pan,wheel_zoom,box_zoom,reset,save')
550
+ p1 = figure(
551
+ width=width,
552
+ height=height,
553
+ title="Original RT",
554
+ x_axis_label="Retention Time (s)",
555
+ y_axis_label="m/z",
556
+ tools="pan,wheel_zoom,box_zoom,reset,save",
557
+ )
287
558
  p1.outline_line_color = None
288
- p1.background_fill_color = 'white'
289
- p1.border_fill_color = 'white'
559
+ p1.background_fill_color = "white"
560
+ p1.border_fill_color = "white"
290
561
  p1.min_border = 0
291
562
 
292
- p2 = figure(width=width, height=height, title='Current RT', x_axis_label='Retention Time (s)', y_axis_label='m/z', tools='pan,wheel_zoom,box_zoom,reset,save', x_range=p1.x_range, y_range=p1.y_range)
563
+ p2 = figure(
564
+ width=width,
565
+ height=height,
566
+ title="Current RT",
567
+ x_axis_label="Retention Time (s)",
568
+ y_axis_label="m/z",
569
+ tools="pan,wheel_zoom,box_zoom,reset,save",
570
+ x_range=p1.x_range,
571
+ y_range=p1.y_range,
572
+ )
293
573
  p2.outline_line_color = None
294
- p2.background_fill_color = 'white'
295
- p2.border_fill_color = 'white'
574
+ p2.background_fill_color = "white"
575
+ p2.border_fill_color = "white"
296
576
  p2.min_border = 0
297
-
577
+
298
578
  # Get unique sample indices for iteration
299
- unique_samples = sorted(list(set(item['sample_idx'] for item in before_data))) if before_data else []
579
+ unique_samples = sorted(list({item["sample_idx"] for item in before_data})) if before_data else []
300
580
 
301
581
  renderers_before = []
302
582
  renderers_after = []
303
583
 
304
584
  for sample_idx in unique_samples:
305
- sb = before_df[before_df['sample_idx'] == sample_idx]
306
- sa = after_df[after_df['sample_idx'] == sample_idx]
307
- color = color_map.get(sample_idx, '#000000')
585
+ sb = before_df[before_df["sample_idx"] == sample_idx]
586
+ sa = after_df[after_df["sample_idx"] == sample_idx]
587
+ color = color_map.get(sample_idx, "#000000")
308
588
 
309
589
  if not sb.empty:
310
590
  src = ColumnDataSource(sb)
311
- r = p1.scatter('rt', 'mz', size='size', color=color, alpha='alpha', source=src)
591
+ r = p1.scatter("rt", "mz", size="size", color=color, alpha="alpha", source=src)
312
592
  renderers_before.append(r)
313
593
 
314
594
  if not sa.empty:
315
595
  src = ColumnDataSource(sa)
316
- r = p2.scatter('rt', 'mz', size='size', color=color, alpha='alpha', source=src)
596
+ r = p2.scatter("rt", "mz", size="size", color=color, alpha="alpha", source=src)
317
597
  renderers_after.append(r)
318
598
 
319
599
  # Add hover tools
320
- hover1 = HoverTool(tooltips=[('Sample UID', '@sample_uid'), ('Sample Name', '@sample_name'), ('Sample Color', '$color[swatch]:sample_color'), ('RT', '@rt{0.00}'), ('m/z', '@mz{0.0000}'), ('Intensity', '@inty{0.0e0}')], renderers=renderers_before)
600
+ hover1 = HoverTool(
601
+ tooltips=[
602
+ ("Sample UID", "@sample_uid"),
603
+ ("Sample Name", "@sample_name"),
604
+ ("Sample Color", "$color[swatch]:sample_color"),
605
+ ("RT", "@rt{0.00}"),
606
+ ("m/z", "@mz{0.0000}"),
607
+ ("Intensity", "@inty{0.0e0}"),
608
+ ],
609
+ renderers=renderers_before,
610
+ )
321
611
  p1.add_tools(hover1)
322
612
 
323
- hover2 = HoverTool(tooltips=[('Sample UID', '@sample_uid'), ('Sample Name', '@sample_name'), ('Sample Color', '$color[swatch]:sample_color'), ('RT', '@rt{0.00}'), ('m/z', '@mz{0.0000}'), ('Intensity', '@inty{0.0e0}')], renderers=renderers_after)
613
+ hover2 = HoverTool(
614
+ tooltips=[
615
+ ("Sample UID", "@sample_uid"),
616
+ ("Sample Name", "@sample_name"),
617
+ ("Sample Color", "$color[swatch]:sample_color"),
618
+ ("RT", "@rt{0.00}"),
619
+ ("m/z", "@mz{0.0000}"),
620
+ ("Intensity", "@inty{0.0e0}"),
621
+ ],
622
+ renderers=renderers_after,
623
+ )
324
624
  p2.add_tools(hover2)
325
625
 
326
626
  # Create layout with both plots side by side
327
627
  # Use the aliased bokeh_row and set sizing_mode, width and height to avoid validation warnings.
328
- layout = bokeh_row(p1, p2, sizing_mode='fixed', width=width, height=height)
628
+ layout = bokeh_row(p1, p2, sizing_mode="fixed", width=width, height=height)
329
629
 
330
- # Output and show
331
- if filename:
332
- from bokeh.plotting import output_file, show
333
- output_file(filename)
334
- show(layout)
630
+ # Apply consistent save/display behavior
631
+ if filename is not None:
632
+ # Convert relative paths to absolute paths using study folder as base
633
+ import os
634
+ if not os.path.isabs(filename):
635
+ filename = os.path.join(self.folder, filename)
636
+
637
+ # Convert to absolute path for logging
638
+ abs_filename = os.path.abspath(filename)
639
+
640
+ # Use isolated file saving
641
+ _isolated_save_plot(layout, filename, abs_filename, self.logger, "Alignment Plot")
335
642
  else:
336
- from bokeh.plotting import show
337
- show(layout)
643
+ # Show in notebook when no filename provided
644
+ _isolated_show_notebook(layout)
338
645
 
339
646
  return layout
340
647
 
@@ -439,20 +746,21 @@ def plot_consensus_2d(
439
746
  from bokeh.models import ColumnDataSource
440
747
  from bokeh.models import HoverTool
441
748
  from bokeh.models import LinearColorMapper
749
+ from bokeh.io.export import export_png
442
750
 
443
751
  try:
444
752
  from bokeh.models import ColorBar # type: ignore[attr-defined]
445
753
  except ImportError:
446
754
  from bokeh.models.annotations import ColorBar
447
755
  from bokeh.palettes import viridis
448
-
756
+
449
757
  # Import cmap for colormap handling
450
758
  from cmap import Colormap
451
759
 
452
760
  # Convert Polars DataFrame to pandas for Bokeh compatibility
453
761
  data_pd = data.to_pandas()
454
762
  source = ColumnDataSource(data_pd)
455
-
763
+
456
764
  # Handle colormap using cmap.Colormap
457
765
  try:
458
766
  # Get colormap palette using cmap
@@ -461,6 +769,7 @@ def plot_consensus_2d(
461
769
  # Generate 256 colors and convert to hex
462
770
  import numpy as np
463
771
  import matplotlib.colors as mcolors
772
+
464
773
  colors = colormap(np.linspace(0, 1, 256))
465
774
  palette = [mcolors.rgb2hex(color) for color in colors]
466
775
  else:
@@ -473,19 +782,21 @@ def plot_consensus_2d(
473
782
  # Fall back to generating colors manually
474
783
  import numpy as np
475
784
  import matplotlib.colors as mcolors
785
+
476
786
  colors = colormap(np.linspace(0, 1, 256))
477
787
  palette = [mcolors.rgb2hex(color) for color in colors]
478
788
  except AttributeError:
479
789
  # Fall back to generating colors manually
480
790
  import numpy as np
481
791
  import matplotlib.colors as mcolors
792
+
482
793
  colors = colormap(np.linspace(0, 1, 256))
483
794
  palette = [mcolors.rgb2hex(color) for color in colors]
484
795
  except (AttributeError, ValueError, TypeError) as e:
485
796
  # Fallback to viridis if cmap interpretation fails
486
797
  self.logger.warning(f"Could not interpret colormap '{cmap}': {e}, falling back to viridis")
487
798
  palette = viridis(256)
488
-
799
+
489
800
  color_mapper = LinearColorMapper(
490
801
  palette=palette,
491
802
  low=data[colorby].min(),
@@ -549,8 +860,19 @@ def plot_consensus_2d(
549
860
  p.add_layout(color_bar, "right")
550
861
 
551
862
  if filename is not None:
552
- bp.output_file(filename)
553
- bp.show(p)
863
+ # Convert relative paths to absolute paths using study folder as base
864
+ import os
865
+ if not os.path.isabs(filename):
866
+ filename = os.path.join(self.folder, filename)
867
+
868
+ # Convert to absolute path for logging
869
+ abs_filename = os.path.abspath(filename)
870
+
871
+ # Use isolated file saving
872
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "Consensus 2D Plot")
873
+ else:
874
+ # Show in notebook when no filename provided
875
+ _isolated_show_notebook(p)
554
876
  return p
555
877
 
556
878
 
@@ -603,8 +925,7 @@ def plot_samples_2d(
603
925
 
604
926
  # Get sample colors from samples_df
605
927
  sample_colors = (
606
- self.samples_df
607
- .filter(pl.col("sample_uid").is_in(sample_uids))
928
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
608
929
  .select(["sample_uid", "sample_color"])
609
930
  .to_dict(as_series=False)
610
931
  )
@@ -770,17 +1091,22 @@ def plot_samples_2d(
770
1091
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
771
1092
  if getattr(p, "legend", None) and len(p.legend) > 0:
772
1093
  p.legend.visible = False
773
- if filename:
774
- if filename.endswith(".html"):
775
- output_file(filename)
776
- show(p)
777
- elif filename.endswith(".png"):
778
- export_png(p, filename=filename)
779
- else:
780
- output_file(filename)
781
- show(p)
1094
+
1095
+ # Apply consistent save/display behavior
1096
+ if filename is not None:
1097
+ # Convert relative paths to absolute paths using study folder as base
1098
+ import os
1099
+ if not os.path.isabs(filename):
1100
+ filename = os.path.join(self.folder, filename)
1101
+
1102
+ # Convert to absolute path for logging
1103
+ abs_filename = os.path.abspath(filename)
1104
+
1105
+ # Use isolated file saving
1106
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "Samples 2D Plot")
782
1107
  else:
783
- show(p)
1108
+ # Show in notebook when no filename provided
1109
+ _isolated_show_notebook(p)
784
1110
  return
785
1111
 
786
1112
 
@@ -794,7 +1120,7 @@ def plot_bpc(
794
1120
  original: bool = False,
795
1121
  ):
796
1122
  """
797
- Plot Base Peak Chromatograms (BPC) for selected samples overlayed using Bokeh.
1123
+ Plot Base Peak Chromatograms (BPC) for selected samples overlaid using Bokeh.
798
1124
 
799
1125
  This collects per-sample BPCs via `get_bpc(self, sample=uid)` and overlays them.
800
1126
  Colors are mapped per-sample using the same Turbo256 palette as `plot_samples_2d`.
@@ -818,8 +1144,7 @@ def plot_bpc(
818
1144
 
819
1145
  # Get sample colors from samples_df
820
1146
  sample_colors = (
821
- self.samples_df
822
- .filter(pl.col("sample_uid").is_in(sample_uids))
1147
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
823
1148
  .select(["sample_uid", "sample_color"])
824
1149
  .to_dict(as_series=False)
825
1150
  )
@@ -836,7 +1161,7 @@ def plot_bpc(
836
1161
  for uid in sample_uids:
837
1162
  try:
838
1163
  first_chrom = get_bpc(self, sample=uid, label=None, original=original)
839
- if hasattr(first_chrom, 'rt_unit'):
1164
+ if hasattr(first_chrom, "rt_unit"):
840
1165
  rt_unit = first_chrom.rt_unit
841
1166
  break
842
1167
  except Exception:
@@ -867,7 +1192,11 @@ def plot_bpc(
867
1192
  # extract arrays
868
1193
  try:
869
1194
  # prefer Chromatogram API
870
- chrom_dict = chrom.to_dict() if hasattr(chrom, "to_dict") else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
1195
+ chrom_dict = (
1196
+ chrom.to_dict()
1197
+ if hasattr(chrom, "to_dict")
1198
+ else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
1199
+ )
871
1200
  rt = chrom_dict.get("rt")
872
1201
  inty = chrom_dict.get("inty")
873
1202
  except Exception:
@@ -907,7 +1236,7 @@ def plot_bpc(
907
1236
 
908
1237
  # Debug: log sample processing details
909
1238
  self.logger.debug(
910
- f"Processing BPC for sample_uid={uid}, sample_name={sample_name}, rt_len={rt.size}, color={color}"
1239
+ f"Processing BPC for sample_uid={uid}, sample_name={sample_name}, rt_len={rt.size}, color={color}",
911
1240
  )
912
1241
 
913
1242
  data = {"rt": rt, "inty": inty, "sample": [sample_name] * len(rt), "sample_color": [color] * len(rt)}
@@ -921,29 +1250,36 @@ def plot_bpc(
921
1250
  self.logger.warning("No BPC curves to plot for the selected samples.")
922
1251
  return
923
1252
 
924
- hover = HoverTool(tooltips=[("sample", "@sample"), ("sample_color", "$color[swatch]:sample_color"), ("rt", "@rt{0.00}"), ("inty", "@inty{0.00e0}")], renderers=renderers)
1253
+ hover = HoverTool(
1254
+ tooltips=[
1255
+ ("sample", "@sample"),
1256
+ ("sample_color", "$color[swatch]:sample_color"),
1257
+ ("rt", "@rt{0.00}"),
1258
+ ("inty", "@inty{0.00e0}"),
1259
+ ],
1260
+ renderers=renderers,
1261
+ )
925
1262
  p.add_tools(hover)
926
1263
 
927
1264
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
928
1265
  if getattr(p, "legend", None) and len(p.legend) > 0:
929
1266
  p.legend.visible = False
930
1267
 
931
- if filename:
932
- if filename.endswith(".html"):
933
- output_file(filename)
934
- show(p)
935
- elif filename.endswith(".png"):
936
- try:
937
- export_png(p, filename=filename)
938
- except Exception:
939
- # fallback to saving HTML
940
- output_file(filename.replace(".png", ".html"))
941
- show(p)
942
- else:
943
- output_file(filename)
944
- show(p)
1268
+ # Apply consistent save/display behavior
1269
+ if filename is not None:
1270
+ # Convert relative paths to absolute paths using study folder as base
1271
+ import os
1272
+ if not os.path.isabs(filename):
1273
+ filename = os.path.join(self.folder, filename)
1274
+
1275
+ # Convert to absolute path for logging
1276
+ abs_filename = os.path.abspath(filename)
1277
+
1278
+ # Use isolated file saving
1279
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "BPC Plot")
945
1280
  else:
946
- show(p)
1281
+ # Show in notebook when no filename provided
1282
+ _isolated_show_notebook(p)
947
1283
 
948
1284
  return p
949
1285
 
@@ -990,8 +1326,7 @@ def plot_eic(
990
1326
 
991
1327
  # Get sample colors from samples_df
992
1328
  sample_colors = (
993
- self.samples_df
994
- .filter(pl.col("sample_uid").is_in(sample_uids))
1329
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
995
1330
  .select(["sample_uid", "sample_color"])
996
1331
  .to_dict(as_series=False)
997
1332
  )
@@ -1004,7 +1339,7 @@ def plot_eic(
1004
1339
  for uid in sample_uids:
1005
1340
  try:
1006
1341
  first_chrom = get_eic(self, sample=uid, mz=mz, mz_tol=mz_tol, label=None)
1007
- if hasattr(first_chrom, 'rt_unit'):
1342
+ if hasattr(first_chrom, "rt_unit"):
1008
1343
  rt_unit = first_chrom.rt_unit
1009
1344
  break
1010
1345
  except Exception:
@@ -1035,7 +1370,11 @@ def plot_eic(
1035
1370
  # extract arrays
1036
1371
  try:
1037
1372
  # prefer Chromatogram API
1038
- chrom_dict = chrom.to_dict() if hasattr(chrom, "to_dict") else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
1373
+ chrom_dict = (
1374
+ chrom.to_dict()
1375
+ if hasattr(chrom, "to_dict")
1376
+ else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
1377
+ )
1039
1378
  rt = chrom_dict.get("rt")
1040
1379
  inty = chrom_dict.get("inty")
1041
1380
  except Exception:
@@ -1083,27 +1422,35 @@ def plot_eic(
1083
1422
  self.logger.warning("No EIC curves to plot for the selected samples.")
1084
1423
  return
1085
1424
 
1086
- hover = HoverTool(tooltips=[("sample", "@sample"), ("sample_color", "$color[swatch]:sample_color"), ("rt", "@rt{0.00}"), ("inty", "@inty{0.0e0}")], renderers=renderers)
1425
+ hover = HoverTool(
1426
+ tooltips=[
1427
+ ("sample", "@sample"),
1428
+ ("sample_color", "$color[swatch]:sample_color"),
1429
+ ("rt", "@rt{0.00}"),
1430
+ ("inty", "@inty{0.0e0}"),
1431
+ ],
1432
+ renderers=renderers,
1433
+ )
1087
1434
  p.add_tools(hover)
1088
1435
 
1089
1436
  if getattr(p, "legend", None) and len(p.legend) > 0:
1090
1437
  p.legend.visible = False
1091
1438
 
1092
- if filename:
1093
- if filename.endswith(".html"):
1094
- output_file(filename)
1095
- show(p)
1096
- elif filename.endswith(".png"):
1097
- try:
1098
- export_png(p, filename=filename)
1099
- except Exception:
1100
- output_file(filename.replace(".png", ".html"))
1101
- show(p)
1102
- else:
1103
- output_file(filename)
1104
- show(p)
1439
+ # Apply consistent save/display behavior
1440
+ if filename is not None:
1441
+ # Convert relative paths to absolute paths using study folder as base
1442
+ import os
1443
+ if not os.path.isabs(filename):
1444
+ filename = os.path.join(self.folder, filename)
1445
+
1446
+ # Convert to absolute path for logging
1447
+ abs_filename = os.path.abspath(filename)
1448
+
1449
+ # Use isolated file saving
1450
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "EIC Plot")
1105
1451
  else:
1106
- show(p)
1452
+ # Show in notebook when no filename provided
1453
+ _isolated_show_notebook(p)
1107
1454
 
1108
1455
  return p
1109
1456
 
@@ -1117,7 +1464,7 @@ def plot_rt_correction(
1117
1464
  height: int = 300,
1118
1465
  ):
1119
1466
  """
1120
- Plot RT correction per sample: (rt - rt_original) vs rt overlayed for selected samples.
1467
+ Plot RT correction per sample: (rt - rt_original) vs rt overlaid for selected samples.
1121
1468
 
1122
1469
  This uses the same color mapping as `plot_bpc` so curves for the same samples match.
1123
1470
  """
@@ -1141,8 +1488,7 @@ def plot_rt_correction(
1141
1488
 
1142
1489
  # Get sample colors from samples_df
1143
1490
  sample_colors = (
1144
- self.samples_df
1145
- .filter(pl.col("sample_uid").is_in(sample_uids))
1491
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
1146
1492
  .select(["sample_uid", "sample_color"])
1147
1493
  .to_dict(as_series=False)
1148
1494
  )
@@ -1228,30 +1574,36 @@ def plot_rt_correction(
1228
1574
  self.logger.warning("No RT correction curves to plot for the selected samples.")
1229
1575
  return
1230
1576
 
1231
- hover = HoverTool(tooltips=[("sample", "@sample"), ("sample_color", "$color[swatch]:sample_color"), ("rt", "@rt{0.00}"), ("rt - rt_original", "@delta{0.00}")], renderers=renderers)
1577
+ hover = HoverTool(
1578
+ tooltips=[
1579
+ ("sample", "@sample"),
1580
+ ("sample_color", "$color[swatch]:sample_color"),
1581
+ ("rt", "@rt{0.00}"),
1582
+ ("rt - rt_original", "@delta{0.00}"),
1583
+ ],
1584
+ renderers=renderers,
1585
+ )
1232
1586
  p.add_tools(hover)
1233
1587
 
1234
1588
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
1235
1589
  if getattr(p, "legend", None) and len(p.legend) > 0:
1236
1590
  p.legend.visible = False
1237
1591
 
1238
- if filename:
1239
- if filename.endswith(".html"):
1240
- output_file(filename)
1241
- show(p)
1242
- elif filename.endswith(".png"):
1243
- try:
1244
- from bokeh.io.export import export_png
1245
-
1246
- export_png(p, filename=filename)
1247
- except Exception:
1248
- output_file(filename.replace(".png", ".html"))
1249
- show(p)
1250
- else:
1251
- output_file(filename)
1252
- show(p)
1592
+ # Apply consistent save/display behavior
1593
+ if filename is not None:
1594
+ # Convert relative paths to absolute paths using study folder as base
1595
+ import os
1596
+ if not os.path.isabs(filename):
1597
+ filename = os.path.join(self.folder, filename)
1598
+
1599
+ # Convert to absolute path for logging
1600
+ abs_filename = os.path.abspath(filename)
1601
+
1602
+ # Use isolated file saving
1603
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "RT Correction Plot")
1253
1604
  else:
1254
- show(p)
1605
+ # Show in notebook when no filename provided
1606
+ _isolated_show_notebook(p)
1255
1607
 
1256
1608
  return p
1257
1609
 
@@ -1280,10 +1632,11 @@ def plot_chrom(
1280
1632
  if not sample_names:
1281
1633
  self.logger.error("No sample names found in chromatogram data.")
1282
1634
  return
1283
-
1635
+
1284
1636
  # Create color mapping by getting sample_color for each sample_name
1285
- samples_info = self.samples_df.select(["sample_name", "sample_color"]).to_dict(as_series=False)
1637
+ samples_info = self.samples_df.select(["sample_name", "sample_color", "sample_uid"]).to_dict(as_series=False)
1286
1638
  sample_name_to_color = dict(zip(samples_info["sample_name"], samples_info["sample_color"]))
1639
+ sample_name_to_uid = dict(zip(samples_info["sample_name"], samples_info["sample_uid"]))
1287
1640
  color_map = {name: sample_name_to_color.get(name, "#1f77b4") for name in sample_names} # fallback to blue
1288
1641
 
1289
1642
  plots = []
@@ -1339,9 +1692,31 @@ def plot_chrom(
1339
1692
  sorted_indices = np.argsort(rt)
1340
1693
  rt = rt[sorted_indices]
1341
1694
  inty = inty[sorted_indices]
1342
- curve = hv.Curve((rt, inty), kdims=["RT"], vdims=["inty"]).opts(
1695
+
1696
+ # Get sample uid for this sample name
1697
+ sample_uid = sample_name_to_uid.get(sample, None)
1698
+ sample_color = color_map.get(sample, "#1f77b4")
1699
+
1700
+ # Create arrays with sample information for hover tooltips
1701
+ sample_names_array = [sample] * len(rt)
1702
+ sample_uids_array = [sample_uid] * len(rt)
1703
+ sample_colors_array = [sample_color] * len(rt)
1704
+
1705
+ curve = hv.Curve(
1706
+ (rt, inty, sample_names_array, sample_uids_array, sample_colors_array),
1707
+ kdims=["RT"],
1708
+ vdims=["inty", "sample_name", "sample_uid", "sample_color"]
1709
+ ).opts(
1343
1710
  color=color_map[sample],
1344
1711
  line_width=1,
1712
+ tools=["hover"],
1713
+ hover_tooltips=[
1714
+ ("RT", "@RT{0.00}"),
1715
+ ("Intensity", "@inty{0,0}"),
1716
+ ("Sample Name", "@sample_name"),
1717
+ ("Sample UID", "@sample_uid"),
1718
+ ("Sample Color", "$color[swatch]:sample_color")
1719
+ ]
1345
1720
  )
1346
1721
  curves.append(curve)
1347
1722
 
@@ -1400,23 +1775,30 @@ def plot_chrom(
1400
1775
  # stack vertically.
1401
1776
  # Stack all plots vertically in a Panel column
1402
1777
  layout = panel.Column(*[panel.panel(plot) for plot in plots])
1778
+
1779
+ # Apply consistent save/display behavior
1403
1780
  if filename is not None:
1404
- if filename.endswith(".html"):
1405
- panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
1406
- else:
1407
- # Save as PNG using Panel's export_png if filename ends with .png
1408
- if filename.endswith(".png"):
1409
- from panel.io.save import save_png
1410
-
1411
- # Convert Holoviews overlays to Bokeh models before saving
1412
- bokeh_layout = panel.panel(layout).get_root() # type: ignore[attr-defined]
1413
- save_png(bokeh_layout, filename=filename)
1414
- else:
1415
- panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
1781
+ # Convert relative paths to absolute paths using study folder as base
1782
+ import os
1783
+ if not os.path.isabs(filename):
1784
+ filename = os.path.join(self.folder, filename)
1785
+
1786
+ # Convert to absolute path for logging
1787
+ abs_filename = os.path.abspath(filename)
1788
+
1789
+ # Use isolated Panel saving
1790
+ _isolated_save_panel_plot(panel.panel(layout), filename, abs_filename, self.logger, "Chromatogram Plot")
1416
1791
  else:
1417
- # In a server context, return the panel object instead of showing or saving directly
1418
- # return panel.panel(layout)
1419
- panel.panel(layout).show()
1792
+ # Show in notebook when no filename provided
1793
+ # Convert Panel layout to Bokeh layout for consistent isolated display
1794
+ try:
1795
+ panel_obj = panel.panel(layout)
1796
+ bokeh_layout = panel_obj.get_root()
1797
+ # Use the regular isolated show method for Bokeh objects
1798
+ _isolated_show_notebook(bokeh_layout)
1799
+ except Exception:
1800
+ # Fallback to Panel display if conversion fails
1801
+ _isolated_show_panel_notebook(panel.panel(layout))
1420
1802
 
1421
1803
 
1422
1804
  def plot_consensus_stats(
@@ -1643,11 +2025,21 @@ def plot_consensus_stats(
1643
2025
  if hasattr(grid, "border_fill_color"):
1644
2026
  grid.border_fill_color = "white"
1645
2027
 
1646
- # Output and show
1647
- if filename:
1648
- output_file(filename)
1649
-
1650
- show(grid)
2028
+ # Apply consistent save/display behavior
2029
+ if filename is not None:
2030
+ # Convert relative paths to absolute paths using study folder as base
2031
+ import os
2032
+ if not os.path.isabs(filename):
2033
+ filename = os.path.join(self.folder, filename)
2034
+
2035
+ # Convert to absolute path for logging
2036
+ abs_filename = os.path.abspath(filename)
2037
+
2038
+ # Use isolated file saving
2039
+ _isolated_save_plot(grid, filename, abs_filename, self.logger, "Consensus Stats Plot")
2040
+ else:
2041
+ # Show in notebook when no filename provided
2042
+ _isolated_show_notebook(grid)
1651
2043
  return grid
1652
2044
 
1653
2045
 
@@ -1679,6 +2071,7 @@ def plot_pca(
1679
2071
  from bokeh.plotting import figure, show, output_file
1680
2072
  from bokeh.palettes import Category20, viridis
1681
2073
  from bokeh.transform import factor_cmap
2074
+ from bokeh.io.export import export_png
1682
2075
  from sklearn.decomposition import PCA
1683
2076
  from sklearn.preprocessing import StandardScaler
1684
2077
  import pandas as pd
@@ -1702,21 +2095,22 @@ def plot_pca(
1702
2095
 
1703
2096
  self.logger.debug(f"Performing PCA on consensus matrix with shape: {consensus_matrix.shape}")
1704
2097
 
1705
- # Convert consensus matrix to numpy - handle both Polars and pandas DataFrames
1706
- if hasattr(consensus_matrix, "to_numpy"):
1707
- # Polars or pandas DataFrame
1708
- if hasattr(consensus_matrix, "select"):
1709
- # Polars DataFrame - exclude the consensus_uid column
1710
- numeric_cols = [col for col in consensus_matrix.columns if col != "consensus_uid"]
1711
- matrix_data = consensus_matrix.select(numeric_cols).to_numpy()
1712
- else:
1713
- # Pandas DataFrame
1714
- matrix_data = consensus_matrix.to_numpy()
1715
- elif hasattr(consensus_matrix, "values"):
1716
- # Pandas DataFrame
1717
- matrix_data = consensus_matrix.values
2098
+ # Extract only the sample columns (exclude consensus_uid column)
2099
+ sample_cols = [col for col in consensus_matrix.columns if col != "consensus_uid"]
2100
+
2101
+ # Convert consensus matrix to numpy, excluding the consensus_uid column
2102
+ if hasattr(consensus_matrix, "select"):
2103
+ # Polars DataFrame
2104
+ matrix_data = consensus_matrix.select(sample_cols).to_numpy()
1718
2105
  else:
1719
- matrix_data = np.array(consensus_matrix)
2106
+ # Pandas DataFrame or other - drop consensus_uid column
2107
+ matrix_sample_data = consensus_matrix.drop(columns=["consensus_uid"], errors="ignore")
2108
+ if hasattr(matrix_sample_data, "values"):
2109
+ matrix_data = matrix_sample_data.values
2110
+ elif hasattr(matrix_sample_data, "to_numpy"):
2111
+ matrix_data = matrix_sample_data.to_numpy()
2112
+ else:
2113
+ matrix_data = np.array(matrix_sample_data)
1720
2114
 
1721
2115
  # Transpose matrix so samples are rows and features are columns
1722
2116
  matrix_data = matrix_data.T
@@ -1753,7 +2147,7 @@ def plot_pca(
1753
2147
  else:
1754
2148
  self.logger.warning(
1755
2149
  f"Sample count mismatch: samples_df has {len(samples_pd)} rows, "
1756
- f"but consensus matrix has {len(pca_df)} samples"
2150
+ f"but consensus matrix has {len(pca_df)} samples",
1757
2151
  )
1758
2152
 
1759
2153
  # Prepare color mapping
@@ -1824,25 +2218,23 @@ def plot_pca(
1824
2218
  if "sample_uid" in pca_df.columns or "sample_name" in pca_df.columns:
1825
2219
  # Choose the identifier to map colors by
1826
2220
  id_col = "sample_uid" if "sample_uid" in pca_df.columns else "sample_name"
1827
-
2221
+
1828
2222
  # Get colors from samples_df based on the identifier
1829
2223
  if id_col == "sample_uid":
1830
2224
  sample_colors = (
1831
- self.samples_df
1832
- .filter(pl.col("sample_uid").is_in(pca_df[id_col].unique()))
2225
+ self.samples_df.filter(pl.col("sample_uid").is_in(pca_df[id_col].unique()))
1833
2226
  .select(["sample_uid", "sample_color"])
1834
2227
  .to_dict(as_series=False)
1835
2228
  )
1836
2229
  color_map = dict(zip(sample_colors["sample_uid"], sample_colors["sample_color"]))
1837
2230
  else: # sample_name
1838
2231
  sample_colors = (
1839
- self.samples_df
1840
- .filter(pl.col("sample_name").is_in(pca_df[id_col].unique()))
2232
+ self.samples_df.filter(pl.col("sample_name").is_in(pca_df[id_col].unique()))
1841
2233
  .select(["sample_name", "sample_color"])
1842
2234
  .to_dict(as_series=False)
1843
2235
  )
1844
2236
  color_map = dict(zip(sample_colors["sample_name"], sample_colors["sample_color"]))
1845
-
2237
+
1846
2238
  # Map colors into dataframe
1847
2239
  pca_df["color"] = [color_map.get(x, "#1f77b4") for x in pca_df[id_col]] # fallback to blue
1848
2240
  # Update the ColumnDataSource with new color column
@@ -1878,7 +2270,7 @@ def plot_pca(
1878
2270
  if col in pca_df.columns:
1879
2271
  if col == "sample_color":
1880
2272
  # Display sample_color as a colored swatch
1881
- tooltip_list.append(('color', "$color[swatch]:sample_color"))
2273
+ tooltip_list.append(("color", "$color[swatch]:sample_color"))
1882
2274
  elif pca_df[col].dtype in ["float64", "float32"]:
1883
2275
  tooltip_list.append((col, f"@{col}{{0.00}}"))
1884
2276
  else:
@@ -1897,13 +2289,24 @@ def plot_pca(
1897
2289
  p.legend.location = "top_left"
1898
2290
  p.legend.click_policy = "hide"
1899
2291
 
1900
- # Output and show
1901
- if filename:
1902
- output_file(filename)
1903
-
1904
- show(p)
2292
+ # Apply consistent save/display behavior
2293
+ if filename is not None:
2294
+ # Convert relative paths to absolute paths using study folder as base
2295
+ import os
2296
+ if not os.path.isabs(filename):
2297
+ filename = os.path.join(self.folder, filename)
2298
+
2299
+ # Convert to absolute path for logging
2300
+ abs_filename = os.path.abspath(filename)
2301
+
2302
+ # Use isolated file saving
2303
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "PCA Plot")
2304
+ else:
2305
+ # Show in notebook when no filename provided
2306
+ _isolated_show_notebook(p)
1905
2307
  return p
1906
2308
 
2309
+
1907
2310
  def plot_tic(
1908
2311
  self,
1909
2312
  samples=None,
@@ -1914,7 +2317,7 @@ def plot_tic(
1914
2317
  original: bool = False,
1915
2318
  ):
1916
2319
  """
1917
- Plot Total Ion Chromatograms (TIC) for selected samples overlayed using Bokeh.
2320
+ Plot Total Ion Chromatograms (TIC) for selected samples overlaid using Bokeh.
1918
2321
 
1919
2322
  Parameters and behavior mirror `plot_bpc` but use per-sample TICs (get_tic).
1920
2323
  """
@@ -1931,8 +2334,7 @@ def plot_tic(
1931
2334
 
1932
2335
  # Get sample colors from samples_df
1933
2336
  sample_colors = (
1934
- self.samples_df
1935
- .filter(pl.col("sample_uid").is_in(sample_uids))
2337
+ self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
1936
2338
  .select(["sample_uid", "sample_color"])
1937
2339
  .to_dict(as_series=False)
1938
2340
  )
@@ -1945,7 +2347,7 @@ def plot_tic(
1945
2347
  for uid in sample_uids:
1946
2348
  try:
1947
2349
  first_chrom = get_tic(self, sample=uid, label=None)
1948
- if hasattr(first_chrom, 'rt_unit'):
2350
+ if hasattr(first_chrom, "rt_unit"):
1949
2351
  rt_unit = first_chrom.rt_unit
1950
2352
  break
1951
2353
  except Exception:
@@ -1974,7 +2376,11 @@ def plot_tic(
1974
2376
 
1975
2377
  # extract arrays
1976
2378
  try:
1977
- chrom_dict = chrom.to_dict() if hasattr(chrom, "to_dict") else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
2379
+ chrom_dict = (
2380
+ chrom.to_dict()
2381
+ if hasattr(chrom, "to_dict")
2382
+ else {"rt": getattr(chrom, "rt"), "inty": getattr(chrom, "inty")}
2383
+ )
1978
2384
  rt = chrom_dict.get("rt")
1979
2385
  inty = chrom_dict.get("inty")
1980
2386
  except Exception:
@@ -2022,27 +2428,35 @@ def plot_tic(
2022
2428
  self.logger.warning("No TIC curves to plot for the selected samples.")
2023
2429
  return
2024
2430
 
2025
- hover = HoverTool(tooltips=[("sample", "@sample"), ("sample_color", "$color[swatch]:sample_color"), ("rt", "@rt{0.00}"), ("inty", "@inty{0.00e0}")], renderers=renderers)
2431
+ hover = HoverTool(
2432
+ tooltips=[
2433
+ ("sample", "@sample"),
2434
+ ("sample_color", "$color[swatch]:sample_color"),
2435
+ ("rt", "@rt{0.00}"),
2436
+ ("inty", "@inty{0.00e0}"),
2437
+ ],
2438
+ renderers=renderers,
2439
+ )
2026
2440
  p.add_tools(hover)
2027
2441
 
2028
2442
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
2029
2443
  if getattr(p, "legend", None) and len(p.legend) > 0:
2030
2444
  p.legend.visible = False
2031
2445
 
2032
- if filename:
2033
- if filename.endswith(".html"):
2034
- output_file(filename)
2035
- show(p)
2036
- elif filename.endswith(".png"):
2037
- try:
2038
- export_png(p, filename=filename)
2039
- except Exception:
2040
- output_file(filename.replace(".png", ".html"))
2041
- show(p)
2042
- else:
2043
- output_file(filename)
2044
- show(p)
2446
+ # Apply consistent save/display behavior
2447
+ if filename is not None:
2448
+ # Convert relative paths to absolute paths using study folder as base
2449
+ import os
2450
+ if not os.path.isabs(filename):
2451
+ filename = os.path.join(self.folder, filename)
2452
+
2453
+ # Convert to absolute path for logging
2454
+ abs_filename = os.path.abspath(filename)
2455
+
2456
+ # Use isolated file saving
2457
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "TIC Plot")
2045
2458
  else:
2046
- show(p)
2459
+ # Show in notebook when no filename provided
2460
+ _isolated_show_notebook(p)
2047
2461
 
2048
2462
  return p