masster 0.3.19__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 (24) 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/lib.py +32 -25
  10. masster/sample/load.py +7 -1
  11. masster/sample/plot.py +111 -26
  12. masster/study/helpers.py +230 -6
  13. masster/study/plot.py +457 -182
  14. masster/study/study.py +4 -0
  15. {masster-0.3.19.dist-info → masster-0.3.20.dist-info}/METADATA +1 -1
  16. {masster-0.3.19.dist-info → masster-0.3.20.dist-info}/RECORD +24 -18
  17. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.mzML +0 -0
  18. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.timeseries.data +0 -0
  19. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff +0 -0
  20. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff.scan +0 -0
  21. /masster/data/{examples → wiff}/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff2 +0 -0
  22. {masster-0.3.19.dist-info → masster-0.3.20.dist-info}/WHEEL +0 -0
  23. {masster-0.3.19.dist-info → masster-0.3.20.dist-info}/entry_points.txt +0 -0
  24. {masster-0.3.19.dist-info → masster-0.3.20.dist-info}/licenses/LICENSE +0 -0
masster/study/plot.py CHANGED
@@ -17,18 +17,222 @@ hv.extension("bokeh")
17
17
  from bokeh.layouts import row as bokeh_row
18
18
 
19
19
 
20
- <<<<<<< Updated upstream
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
+
21
227
  def plot_alignment(
22
228
  self,
229
+ samples=None,
23
230
  maps: bool = True,
24
231
  filename: str | None = None,
25
232
  width: int = 450,
26
233
  height: int = 450,
27
234
  markersize: int = 3,
28
235
  ):
29
- =======
30
- 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):
31
- >>>>>>> Stashed changes
32
236
  """Visualize retention time alignment using two synchronized Bokeh scatter plots.
33
237
 
34
238
  - When ``maps=True`` the function reads ``self.features_maps`` (list of FeatureMap)
@@ -37,12 +241,8 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
37
241
  ``rt_original`` column (before) and ``rt`` column (after).
38
242
 
39
243
  Parameters
244
+ - samples: List of sample identifiers (sample_uids or sample_names), or single int for random selection, or None for all samples.
40
245
  - maps: whether to use feature maps (default True).
41
- - samples: Sample selection parameter, interpreted like in plot_samples_2d:
42
- - None: show all samples
43
- - int: show a random subset of N samples
44
- - list of ints: show samples with these sample_uids
45
- - list of strings: show samples with these sample_names
46
246
  - filename: optional HTML file path to save the plot.
47
247
  - width/height: pixel size of each subplot.
48
248
  - markersize: base marker size.
@@ -55,6 +255,9 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
55
255
  from bokeh.plotting import figure, show, output_file
56
256
  import pandas as pd
57
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
+
58
261
  # Build the before/after tabular data used for plotting
59
262
  before_data: list[dict[str, Any]] = []
60
263
  after_data: list[dict[str, Any]] = []
@@ -70,31 +273,39 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
70
273
  self.logger.error("No feature maps available for plotting.")
71
274
  return
72
275
 
73
- # Get sample_uids to limit which samples to show
74
- sample_uids_to_show = self._get_sample_uids(samples)
75
-
76
- # Filter feature maps based on sample selection
77
- if sample_uids_to_show is not None:
78
- # Get sample indices for the selected sample_uids
79
- selected_indices = []
80
- 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():
81
280
  samples_info = self.samples_df.to_pandas()
82
- for idx, row in samples_info.iterrows():
83
- if row.get('sample_uid') in sample_uids_to_show:
84
- 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
85
303
  else:
86
- # If no samples_df, just limit to the first N samples
87
- if isinstance(samples, int):
88
- selected_indices = list(range(min(samples, len(fmaps))))
89
- else:
90
- selected_indices = list(range(len(fmaps)))
91
-
92
- # Filter feature maps to only include selected indices
93
- fmaps = [fmaps[i] for i in selected_indices if i < len(fmaps)]
94
-
95
- if not fmaps:
96
- self.logger.error("No feature maps match the selected samples.")
97
- 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
98
309
 
99
310
  # Reference (first) sample: use current RT for both before and after
100
311
  ref = fmaps[0]
@@ -103,9 +314,10 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
103
314
  ref_inty = [f.getIntensity() for f in ref]
104
315
  max_ref_inty = max(ref_inty) if ref_inty else 1
105
316
 
106
- # sample metadata
317
+ # Get sample metadata for reference (first) sample
107
318
  if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
108
- samples_info = self.samples_df.to_pandas()
319
+ if 'samples_info' not in locals():
320
+ samples_info = self.samples_df.to_pandas()
109
321
  ref_sample_uid = (
110
322
  samples_info.iloc[0]["sample_uid"] if "sample_uid" in samples_info.columns else "Reference_UID"
111
323
  )
@@ -138,7 +350,7 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
138
350
  "size": markersize + 2,
139
351
  })
140
352
 
141
- # Remaining samples
353
+ # Remaining samples - now using filtered feature maps and samples_info
142
354
  for sample_idx, fm in enumerate(fmaps[1:], start=1):
143
355
  mz_vals = []
144
356
  inty_vals = []
@@ -165,14 +377,21 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
165
377
 
166
378
  max_inty = max(inty_vals)
167
379
 
380
+ # Get sample metadata from filtered samples_info
168
381
  if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
169
- samples_info = self.samples_df.to_pandas()
170
- if sample_idx < len(samples_info):
382
+ # Use filtered samples_info if it exists from the filtering above
383
+ if 'samples_info' in locals() and sample_idx < len(samples_info):
171
384
  sample_name = samples_info.iloc[sample_idx].get("sample_name", f"Sample {sample_idx}")
172
385
  sample_uid = samples_info.iloc[sample_idx].get("sample_uid", f"Sample_{sample_idx}_UID")
173
386
  else:
174
- sample_name = f"Sample {sample_idx}"
175
- 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"
176
395
  else:
177
396
  sample_name = f"Sample {sample_idx}"
178
397
  sample_uid = f"Sample_{sample_idx}_UID"
@@ -219,34 +438,19 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
219
438
 
220
439
  # Use Polars instead of pandas
221
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
222
448
 
223
449
  sample_col = "sample_uid" if "sample_uid" in features_df.columns else "sample_name"
224
450
  if sample_col not in features_df.columns:
225
451
  self.logger.error("No sample identifier column found in features_df.")
226
452
  return
227
453
 
228
- # Get sample_uids to limit which samples to show
229
- sample_uids_to_show = self._get_sample_uids(samples)
230
-
231
- # Filter features_df based on sample selection if specified
232
- if sample_uids_to_show is not None:
233
- if sample_col == 'sample_uid':
234
- features_df = features_df.filter(pl.col('sample_uid').is_in(sample_uids_to_show))
235
- else:
236
- # Need to convert sample names to sample_uids if using sample_name column
237
- if 'sample_uid' in features_df.columns:
238
- # Filter by sample_uid even though we're using sample_name as the primary column
239
- features_df = features_df.filter(pl.col('sample_uid').is_in(sample_uids_to_show))
240
- else:
241
- # Convert sample_uids to sample_names and filter
242
- sample_names_to_show = []
243
- if hasattr(self, 'samples_df') and self.samples_df is not None:
244
- for uid in sample_uids_to_show:
245
- matching_rows = self.samples_df.filter(pl.col("sample_uid") == uid)
246
- if not matching_rows.is_empty():
247
- sample_names_to_show.append(matching_rows.row(0, named=True)["sample_name"])
248
- features_df = features_df.filter(pl.col('sample_name').is_in(sample_names_to_show))
249
-
250
454
  # Get unique samples using Polars
251
455
  samples = features_df.select(pl.col(sample_col)).unique().to_series().to_list()
252
456
 
@@ -423,16 +627,21 @@ def plot_alignment(self, maps: bool = True, samples: int | list[int | str] | Non
423
627
  # Use the aliased bokeh_row and set sizing_mode, width and height to avoid validation warnings.
424
628
  layout = bokeh_row(p1, p2, sizing_mode="fixed", width=width, height=height)
425
629
 
426
- # Output and show
427
- if filename:
428
- from bokeh.plotting import output_file, show
429
-
430
- output_file(filename)
431
- 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")
432
642
  else:
433
- from bokeh.plotting import show
434
-
435
- show(layout)
643
+ # Show in notebook when no filename provided
644
+ _isolated_show_notebook(layout)
436
645
 
437
646
  return layout
438
647
 
@@ -537,6 +746,7 @@ def plot_consensus_2d(
537
746
  from bokeh.models import ColumnDataSource
538
747
  from bokeh.models import HoverTool
539
748
  from bokeh.models import LinearColorMapper
749
+ from bokeh.io.export import export_png
540
750
 
541
751
  try:
542
752
  from bokeh.models import ColorBar # type: ignore[attr-defined]
@@ -650,8 +860,19 @@ def plot_consensus_2d(
650
860
  p.add_layout(color_bar, "right")
651
861
 
652
862
  if filename is not None:
653
- bp.output_file(filename)
654
- 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)
655
876
  return p
656
877
 
657
878
 
@@ -870,17 +1091,22 @@ def plot_samples_2d(
870
1091
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
871
1092
  if getattr(p, "legend", None) and len(p.legend) > 0:
872
1093
  p.legend.visible = False
873
- if filename:
874
- if filename.endswith(".html"):
875
- output_file(filename)
876
- show(p)
877
- elif filename.endswith(".png"):
878
- export_png(p, filename=filename)
879
- else:
880
- output_file(filename)
881
- 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")
882
1107
  else:
883
- show(p)
1108
+ # Show in notebook when no filename provided
1109
+ _isolated_show_notebook(p)
884
1110
  return
885
1111
 
886
1112
 
@@ -1039,22 +1265,21 @@ def plot_bpc(
1039
1265
  if getattr(p, "legend", None) and len(p.legend) > 0:
1040
1266
  p.legend.visible = False
1041
1267
 
1042
- if filename:
1043
- if filename.endswith(".html"):
1044
- output_file(filename)
1045
- show(p)
1046
- elif filename.endswith(".png"):
1047
- try:
1048
- export_png(p, filename=filename)
1049
- except Exception:
1050
- # fallback to saving HTML
1051
- output_file(filename.replace(".png", ".html"))
1052
- show(p)
1053
- else:
1054
- output_file(filename)
1055
- 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")
1056
1280
  else:
1057
- show(p)
1281
+ # Show in notebook when no filename provided
1282
+ _isolated_show_notebook(p)
1058
1283
 
1059
1284
  return p
1060
1285
 
@@ -1211,21 +1436,21 @@ def plot_eic(
1211
1436
  if getattr(p, "legend", None) and len(p.legend) > 0:
1212
1437
  p.legend.visible = False
1213
1438
 
1214
- if filename:
1215
- if filename.endswith(".html"):
1216
- output_file(filename)
1217
- show(p)
1218
- elif filename.endswith(".png"):
1219
- try:
1220
- export_png(p, filename=filename)
1221
- except Exception:
1222
- output_file(filename.replace(".png", ".html"))
1223
- show(p)
1224
- else:
1225
- output_file(filename)
1226
- 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")
1227
1451
  else:
1228
- show(p)
1452
+ # Show in notebook when no filename provided
1453
+ _isolated_show_notebook(p)
1229
1454
 
1230
1455
  return p
1231
1456
 
@@ -1364,23 +1589,21 @@ def plot_rt_correction(
1364
1589
  if getattr(p, "legend", None) and len(p.legend) > 0:
1365
1590
  p.legend.visible = False
1366
1591
 
1367
- if filename:
1368
- if filename.endswith(".html"):
1369
- output_file(filename)
1370
- show(p)
1371
- elif filename.endswith(".png"):
1372
- try:
1373
- from bokeh.io.export import export_png
1374
-
1375
- export_png(p, filename=filename)
1376
- except Exception:
1377
- output_file(filename.replace(".png", ".html"))
1378
- show(p)
1379
- else:
1380
- output_file(filename)
1381
- 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")
1382
1604
  else:
1383
- show(p)
1605
+ # Show in notebook when no filename provided
1606
+ _isolated_show_notebook(p)
1384
1607
 
1385
1608
  return p
1386
1609
 
@@ -1411,8 +1634,9 @@ def plot_chrom(
1411
1634
  return
1412
1635
 
1413
1636
  # Create color mapping by getting sample_color for each sample_name
1414
- 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)
1415
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"]))
1416
1640
  color_map = {name: sample_name_to_color.get(name, "#1f77b4") for name in sample_names} # fallback to blue
1417
1641
 
1418
1642
  plots = []
@@ -1468,9 +1692,31 @@ def plot_chrom(
1468
1692
  sorted_indices = np.argsort(rt)
1469
1693
  rt = rt[sorted_indices]
1470
1694
  inty = inty[sorted_indices]
1471
- 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(
1472
1710
  color=color_map[sample],
1473
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
+ ]
1474
1720
  )
1475
1721
  curves.append(curve)
1476
1722
 
@@ -1529,23 +1775,30 @@ def plot_chrom(
1529
1775
  # stack vertically.
1530
1776
  # Stack all plots vertically in a Panel column
1531
1777
  layout = panel.Column(*[panel.panel(plot) for plot in plots])
1778
+
1779
+ # Apply consistent save/display behavior
1532
1780
  if filename is not None:
1533
- if filename.endswith(".html"):
1534
- panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
1535
- else:
1536
- # Save as PNG using Panel's export_png if filename ends with .png
1537
- if filename.endswith(".png"):
1538
- from panel.io.save import save_png
1539
-
1540
- # Convert Holoviews overlays to Bokeh models before saving
1541
- bokeh_layout = panel.panel(layout).get_root() # type: ignore[attr-defined]
1542
- save_png(bokeh_layout, filename=filename)
1543
- else:
1544
- 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")
1545
1791
  else:
1546
- # In a server context, return the panel object instead of showing or saving directly
1547
- # return panel.panel(layout)
1548
- 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))
1549
1802
 
1550
1803
 
1551
1804
  def plot_consensus_stats(
@@ -1772,11 +2025,21 @@ def plot_consensus_stats(
1772
2025
  if hasattr(grid, "border_fill_color"):
1773
2026
  grid.border_fill_color = "white"
1774
2027
 
1775
- # Output and show
1776
- if filename:
1777
- output_file(filename)
1778
-
1779
- 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)
1780
2043
  return grid
1781
2044
 
1782
2045
 
@@ -1808,6 +2071,7 @@ def plot_pca(
1808
2071
  from bokeh.plotting import figure, show, output_file
1809
2072
  from bokeh.palettes import Category20, viridis
1810
2073
  from bokeh.transform import factor_cmap
2074
+ from bokeh.io.export import export_png
1811
2075
  from sklearn.decomposition import PCA
1812
2076
  from sklearn.preprocessing import StandardScaler
1813
2077
  import pandas as pd
@@ -1831,21 +2095,22 @@ def plot_pca(
1831
2095
 
1832
2096
  self.logger.debug(f"Performing PCA on consensus matrix with shape: {consensus_matrix.shape}")
1833
2097
 
1834
- # Convert consensus matrix to numpy - handle both Polars and pandas DataFrames
1835
- if hasattr(consensus_matrix, "to_numpy"):
1836
- # Polars or pandas DataFrame
1837
- if hasattr(consensus_matrix, "select"):
1838
- # Polars DataFrame - exclude the consensus_uid column
1839
- numeric_cols = [col for col in consensus_matrix.columns if col != "consensus_uid"]
1840
- matrix_data = consensus_matrix.select(numeric_cols).to_numpy()
1841
- else:
1842
- # Pandas DataFrame
1843
- matrix_data = consensus_matrix.to_numpy()
1844
- elif hasattr(consensus_matrix, "values"):
1845
- # Pandas DataFrame
1846
- 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()
1847
2105
  else:
1848
- 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)
1849
2114
 
1850
2115
  # Transpose matrix so samples are rows and features are columns
1851
2116
  matrix_data = matrix_data.T
@@ -2024,11 +2289,21 @@ def plot_pca(
2024
2289
  p.legend.location = "top_left"
2025
2290
  p.legend.click_policy = "hide"
2026
2291
 
2027
- # Output and show
2028
- if filename:
2029
- output_file(filename)
2030
-
2031
- 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)
2032
2307
  return p
2033
2308
 
2034
2309
 
@@ -2168,20 +2443,20 @@ def plot_tic(
2168
2443
  if getattr(p, "legend", None) and len(p.legend) > 0:
2169
2444
  p.legend.visible = False
2170
2445
 
2171
- if filename:
2172
- if filename.endswith(".html"):
2173
- output_file(filename)
2174
- show(p)
2175
- elif filename.endswith(".png"):
2176
- try:
2177
- export_png(p, filename=filename)
2178
- except Exception:
2179
- output_file(filename.replace(".png", ".html"))
2180
- show(p)
2181
- else:
2182
- output_file(filename)
2183
- 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")
2184
2458
  else:
2185
- show(p)
2459
+ # Show in notebook when no filename provided
2460
+ _isolated_show_notebook(p)
2186
2461
 
2187
2462
  return p