masster 0.4.0__py3-none-any.whl → 0.4.1__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.
Files changed (54) hide show
  1. masster/__init__.py +8 -8
  2. masster/_version.py +1 -1
  3. masster/chromatogram.py +3 -9
  4. masster/data/libs/README.md +1 -1
  5. masster/data/libs/ccm.csv +120 -120
  6. masster/data/libs/ccm.py +116 -62
  7. masster/data/libs/central_carbon_README.md +1 -1
  8. masster/data/libs/urine.py +161 -65
  9. masster/data/libs/urine_metabolites.csv +4693 -4693
  10. masster/data/wiff/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.mzML +2 -2
  11. masster/logger.py +43 -78
  12. masster/sample/__init__.py +1 -1
  13. masster/sample/adducts.py +264 -338
  14. masster/sample/defaults/find_adducts_def.py +8 -21
  15. masster/sample/defaults/find_features_def.py +1 -6
  16. masster/sample/defaults/get_spectrum_def.py +1 -5
  17. masster/sample/defaults/sample_def.py +1 -5
  18. masster/sample/h5.py +282 -561
  19. masster/sample/helpers.py +75 -131
  20. masster/sample/lib.py +17 -42
  21. masster/sample/load.py +17 -31
  22. masster/sample/parameters.py +2 -6
  23. masster/sample/plot.py +27 -88
  24. masster/sample/processing.py +87 -117
  25. masster/sample/quant.py +51 -57
  26. masster/sample/sample.py +90 -103
  27. masster/sample/sample5_schema.json +44 -44
  28. masster/sample/save.py +12 -35
  29. masster/sample/sciex.py +19 -66
  30. masster/spectrum.py +20 -58
  31. masster/study/__init__.py +1 -1
  32. masster/study/defaults/align_def.py +1 -5
  33. masster/study/defaults/fill_chrom_def.py +1 -5
  34. masster/study/defaults/fill_def.py +1 -5
  35. masster/study/defaults/integrate_chrom_def.py +1 -5
  36. masster/study/defaults/integrate_def.py +1 -5
  37. masster/study/defaults/study_def.py +25 -58
  38. masster/study/export.py +207 -233
  39. masster/study/h5.py +136 -470
  40. masster/study/helpers.py +202 -495
  41. masster/study/helpers_optimized.py +13 -40
  42. masster/study/id.py +110 -213
  43. masster/study/load.py +143 -230
  44. masster/study/plot.py +257 -518
  45. masster/study/processing.py +257 -469
  46. masster/study/save.py +5 -15
  47. masster/study/study.py +276 -379
  48. masster/study/study5_schema.json +96 -96
  49. {masster-0.4.0.dist-info → masster-0.4.1.dist-info}/METADATA +1 -1
  50. masster-0.4.1.dist-info/RECORD +67 -0
  51. masster-0.4.0.dist-info/RECORD +0 -67
  52. {masster-0.4.0.dist-info → masster-0.4.1.dist-info}/WHEEL +0 -0
  53. {masster-0.4.0.dist-info → masster-0.4.1.dist-info}/entry_points.txt +0 -0
  54. {masster-0.4.0.dist-info → masster-0.4.1.dist-info}/licenses/LICENSE +0 -0
masster/study/plot.py CHANGED
@@ -26,71 +26,65 @@ def _isolated_save_plot(plot_object, filename, abs_filename, logger, plot_title=
26
26
  # Use isolated file saving that doesn't affect global output state
27
27
  from bokeh.resources import Resources
28
28
  from bokeh.embed import file_html
29
-
29
+
30
30
  # Create HTML content without affecting global state
31
- resources = Resources(mode="cdn")
31
+ resources = Resources(mode='cdn')
32
32
  html = file_html(plot_object, resources, title=plot_title)
33
-
33
+
34
34
  # Write directly to file
35
- with open(filename, "w", encoding="utf-8") as f:
35
+ with open(filename, 'w', encoding='utf-8') as f:
36
36
  f.write(html)
37
-
37
+
38
38
  logger.info(f"Plot saved to: {abs_filename}")
39
-
39
+
40
40
  elif filename.endswith(".png"):
41
41
  try:
42
42
  from bokeh.io.export import export_png
43
-
44
43
  export_png(plot_object, filename=filename)
45
44
  logger.info(f"Plot saved to: {abs_filename}")
46
45
  except Exception:
47
46
  # Fall back to HTML if PNG export not available
48
- html_filename = filename.replace(".png", ".html")
47
+ html_filename = filename.replace('.png', '.html')
49
48
  from bokeh.resources import Resources
50
49
  from bokeh.embed import file_html
51
-
52
- resources = Resources(mode="cdn")
50
+
51
+ resources = Resources(mode='cdn')
53
52
  html = file_html(plot_object, resources, title=plot_title)
54
-
55
- with open(html_filename, "w", encoding="utf-8") as f:
53
+
54
+ with open(html_filename, 'w', encoding='utf-8') as f:
56
55
  f.write(html)
57
-
58
- logger.warning(
59
- f"PNG export not available, saved as HTML instead: {html_filename}",
60
- )
56
+
57
+ logger.warning(f"PNG export not available, saved as HTML instead: {html_filename}")
61
58
  elif filename.endswith(".pdf"):
62
59
  # Try to save as PDF, fall back to HTML if not available
63
60
  try:
64
61
  from bokeh.io.export import export_pdf
65
-
66
62
  export_pdf(plot_object, filename=filename)
67
63
  logger.info(f"Plot saved to: {abs_filename}")
68
64
  except ImportError:
69
65
  # Fall back to HTML if PDF export not available
70
- html_filename = filename.replace(".pdf", ".html")
66
+ html_filename = filename.replace('.pdf', '.html')
71
67
  from bokeh.resources import Resources
72
68
  from bokeh.embed import file_html
73
-
74
- resources = Resources(mode="cdn")
69
+
70
+ resources = Resources(mode='cdn')
75
71
  html = file_html(plot_object, resources, title=plot_title)
76
-
77
- with open(html_filename, "w", encoding="utf-8") as f:
72
+
73
+ with open(html_filename, 'w', encoding='utf-8') as f:
78
74
  f.write(html)
79
-
80
- logger.warning(
81
- f"PDF export not available, saved as HTML instead: {html_filename}",
82
- )
75
+
76
+ logger.warning(f"PDF export not available, saved as HTML instead: {html_filename}")
83
77
  else:
84
78
  # Default to HTML for unknown extensions using isolated approach
85
79
  from bokeh.resources import Resources
86
80
  from bokeh.embed import file_html
87
-
88
- resources = Resources(mode="cdn")
81
+
82
+ resources = Resources(mode='cdn')
89
83
  html = file_html(plot_object, resources, title=plot_title)
90
-
91
- with open(filename, "w", encoding="utf-8") as f:
84
+
85
+ with open(filename, 'w', encoding='utf-8') as f:
92
86
  f.write(html)
93
-
87
+
94
88
  logger.info(f"Plot saved to: {abs_filename}")
95
89
 
96
90
 
@@ -103,36 +97,28 @@ def _isolated_show_notebook(plot_object):
103
97
  import holoviews as hv
104
98
  import warnings
105
99
  import logging
106
-
100
+
107
101
  # Suppress both warnings and logging messages for the specific Bokeh callback warnings
108
102
  # that occur when Panel components with Python callbacks are converted to standalone Bokeh
109
- bokeh_logger = logging.getLogger("bokeh.embed.util")
103
+ bokeh_logger = logging.getLogger('bokeh.embed.util')
110
104
  original_level = bokeh_logger.level
111
105
  bokeh_logger.setLevel(logging.ERROR) # Suppress WARNING level messages
112
-
106
+
113
107
  with warnings.catch_warnings():
114
- warnings.filterwarnings(
115
- "ignore",
116
- message=".*standalone HTML/JS output.*",
117
- category=UserWarning,
118
- )
119
- warnings.filterwarnings(
120
- "ignore",
121
- message=".*real Python callbacks.*",
122
- category=UserWarning,
123
- )
124
-
108
+ warnings.filterwarnings("ignore", message=".*standalone HTML/JS output.*", category=UserWarning)
109
+ warnings.filterwarnings("ignore", message=".*real Python callbacks.*", category=UserWarning)
110
+
125
111
  try:
126
112
  # First clear all output state
127
113
  reset_output()
128
-
114
+
129
115
  # Set notebook mode
130
116
  output_notebook(hide_banner=True)
131
-
132
- # Reset Holoviews to notebook mode
133
- hv.extension("bokeh", logo=False)
134
- hv.output(backend="bokeh", mode="jupyter")
135
-
117
+
118
+ # Reset Holoviews to notebook mode
119
+ hv.extension('bokeh', logo=False)
120
+ hv.output(backend='bokeh', mode='jupyter')
121
+
136
122
  # Show in notebook
137
123
  show(plot_object)
138
124
  finally:
@@ -143,16 +129,16 @@ def _isolated_show_notebook(plot_object):
143
129
  def _isolated_save_panel_plot(panel_obj, filename, abs_filename, logger, plot_title):
144
130
  """
145
131
  Save a Panel plot using isolated approach that doesn't affect global Bokeh state.
146
-
132
+
147
133
  Args:
148
134
  panel_obj: Panel object to save
149
- filename: Target filename
135
+ filename: Target filename
150
136
  abs_filename: Absolute path for logging
151
137
  logger: Logger instance
152
138
  plot_title: Title for logging
153
139
  """
154
140
  import os # Import os for path operations
155
-
141
+
156
142
  if filename.endswith(".html"):
157
143
  # Panel save method should be isolated but let's be sure
158
144
  try:
@@ -161,44 +147,38 @@ def _isolated_save_panel_plot(panel_obj, filename, abs_filename, logger, plot_ti
161
147
  logger.info(f"{plot_title} saved to: {abs_filename}")
162
148
  except Exception as e:
163
149
  logger.error(f"Failed to save {plot_title}: {e}")
164
-
150
+
165
151
  elif filename.endswith(".png"):
166
152
  try:
167
153
  from panel.io.save import save_png
168
-
169
154
  # Convert Panel to Bokeh models before saving
170
155
  bokeh_layout = panel_obj.get_root()
171
156
  save_png(bokeh_layout, filename=filename)
172
157
  logger.info(f"{plot_title} saved to: {abs_filename}")
173
158
  except Exception:
174
159
  # Fall back to HTML if PNG export not available
175
- html_filename = filename.replace(".png", ".html")
160
+ html_filename = filename.replace('.png', '.html')
176
161
  abs_html_filename = os.path.abspath(html_filename)
177
162
  try:
178
163
  panel_obj.save(html_filename, embed=True)
179
- logger.warning(
180
- f"PNG export not available, saved as HTML instead: {abs_html_filename}",
181
- )
164
+ logger.warning(f"PNG export not available, saved as HTML instead: {abs_html_filename}")
182
165
  except Exception as e:
183
166
  logger.error(f"Failed to save {plot_title} as HTML fallback: {e}")
184
-
167
+
185
168
  elif filename.endswith(".pdf"):
186
169
  # Try to save as PDF, fall back to HTML if not available
187
170
  try:
188
171
  from bokeh.io.export import export_pdf
189
-
190
172
  bokeh_layout = panel_obj.get_root()
191
173
  export_pdf(bokeh_layout, filename=filename)
192
174
  logger.info(f"{plot_title} saved to: {abs_filename}")
193
175
  except ImportError:
194
176
  # Fall back to HTML if PDF export not available
195
- html_filename = filename.replace(".pdf", ".html")
177
+ html_filename = filename.replace('.pdf', '.html')
196
178
  abs_html_filename = os.path.abspath(html_filename)
197
179
  try:
198
180
  panel_obj.save(html_filename, embed=True)
199
- logger.warning(
200
- f"PDF export not available, saved as HTML instead: {abs_html_filename}",
201
- )
181
+ logger.warning(f"PDF export not available, saved as HTML instead: {abs_html_filename}")
202
182
  except Exception as e:
203
183
  logger.error(f"Failed to save {plot_title} as HTML fallback: {e}")
204
184
  else:
@@ -213,33 +193,31 @@ def _isolated_save_panel_plot(panel_obj, filename, abs_filename, logger, plot_ti
213
193
  def _isolated_show_panel_notebook(panel_obj):
214
194
  """
215
195
  Show a Panel plot in notebook with state isolation to prevent browser opening.
216
-
196
+
217
197
  Args:
218
198
  panel_obj: Panel object to display
219
199
  """
220
200
  # Reset Bokeh state completely to prevent browser opening if output_file was called before
221
201
  from bokeh.io import reset_output, output_notebook
222
202
  import holoviews as hv
223
-
203
+
224
204
  # First clear all output state
225
205
  reset_output()
226
-
206
+
227
207
  # Set notebook mode
228
208
  output_notebook(hide_banner=True)
229
-
230
- # Reset Holoviews to notebook mode
231
- hv.extension("bokeh", logo=False)
232
- hv.output(backend="bokeh", mode="jupyter")
233
-
234
- # For Panel objects in notebooks, use on.extension and display inline
235
- import panel as on
236
-
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
237
216
  try:
238
217
  # Configure Panel for notebook display
239
- on.extension("bokeh", inline=True, comms="vscode")
218
+ pn.extension('bokeh', inline=True, comms='vscode')
240
219
  # Use IPython display to show inline instead of show()
241
220
  from IPython.display import display
242
-
243
221
  display(panel_obj)
244
222
  except Exception:
245
223
  # Fallback to regular Panel show
@@ -274,7 +252,7 @@ def plot_alignment(
274
252
  """
275
253
  # Local imports so the module can be used even if bokeh isn't needed elsewhere
276
254
  from bokeh.models import ColumnDataSource, HoverTool
277
- from bokeh.plotting import figure
255
+ from bokeh.plotting import figure, show, output_file
278
256
  import pandas as pd
279
257
 
280
258
  # Get sample_uids to filter by if specified
@@ -298,42 +276,32 @@ def plot_alignment(
298
276
  # Filter feature maps by selected samples if specified
299
277
  if sample_uids is not None:
300
278
  # Create mapping from sample_uid to map_id and filter accordingly
301
- if (
302
- hasattr(self, "samples_df")
303
- and self.samples_df is not None
304
- and not self.samples_df.is_empty()
305
- ):
279
+ if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
306
280
  samples_info = self.samples_df.to_pandas()
307
-
281
+
308
282
  # Filter samples_info to only selected sample_uids and get their map_ids
309
- selected_samples = samples_info[
310
- samples_info["sample_uid"].isin(sample_uids)
311
- ]
283
+ selected_samples = samples_info[samples_info["sample_uid"].isin(sample_uids)]
312
284
  if selected_samples.empty:
313
- self.logger.error(
314
- "No matching samples found for the provided sample_uids.",
315
- )
285
+ self.logger.error("No matching samples found for the provided sample_uids.")
316
286
  return
317
-
287
+
318
288
  # Get the map_ids for selected samples
319
289
  selected_map_ids = selected_samples["map_id"].tolist()
320
-
290
+
321
291
  # Filter feature maps based on map_ids
322
292
  filtered_maps = []
323
293
  for map_id in selected_map_ids:
324
294
  if 0 <= map_id < len(fmaps):
325
295
  filtered_maps.append(fmaps[map_id])
326
-
296
+
327
297
  fmaps = filtered_maps
328
298
  samples_info = selected_samples.reset_index(drop=True)
329
-
299
+
330
300
  if not fmaps:
331
301
  self.logger.error("No feature maps found for the selected samples.")
332
302
  return
333
303
  else:
334
- self.logger.warning(
335
- "Cannot filter feature maps: no samples_df available",
336
- )
304
+ self.logger.warning("Cannot filter feature maps: no samples_df available")
337
305
 
338
306
  if not fmaps:
339
307
  self.logger.error("No feature maps available after filtering.")
@@ -347,52 +315,40 @@ def plot_alignment(
347
315
  max_ref_inty = max(ref_inty) if ref_inty else 1
348
316
 
349
317
  # Get sample metadata for reference (first) sample
350
- if (
351
- hasattr(self, "samples_df")
352
- and self.samples_df is not None
353
- and not self.samples_df.is_empty()
354
- ):
355
- if "samples_info" not in locals():
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():
356
320
  samples_info = self.samples_df.to_pandas()
357
321
  ref_sample_uid = (
358
- samples_info.iloc[0]["sample_uid"]
359
- if "sample_uid" in samples_info.columns
360
- else "Reference_UID"
322
+ samples_info.iloc[0]["sample_uid"] if "sample_uid" in samples_info.columns else "Reference_UID"
361
323
  )
362
324
  ref_sample_name = (
363
- samples_info.iloc[0]["sample_name"]
364
- if "sample_name" in samples_info.columns
365
- else "Reference"
325
+ samples_info.iloc[0]["sample_name"] if "sample_name" in samples_info.columns else "Reference"
366
326
  )
367
327
  else:
368
328
  ref_sample_uid = "Reference_UID"
369
329
  ref_sample_name = "Reference"
370
330
 
371
331
  for rt, mz, inty in zip(ref_rt, ref_mz, ref_inty):
372
- before_data.append(
373
- {
374
- "rt": rt,
375
- "mz": mz,
376
- "inty": inty,
377
- "alpha": inty / max_ref_inty,
378
- "sample_idx": 0,
379
- "sample_name": ref_sample_name,
380
- "sample_uid": ref_sample_uid,
381
- "size": markersize + 2,
382
- },
383
- )
384
- after_data.append(
385
- {
386
- "rt": rt,
387
- "mz": mz,
388
- "inty": inty,
389
- "alpha": inty / max_ref_inty,
390
- "sample_idx": 0,
391
- "sample_name": ref_sample_name,
392
- "sample_uid": ref_sample_uid,
393
- "size": markersize + 2,
394
- },
395
- )
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
+ })
396
352
 
397
353
  # Remaining samples - now using filtered feature maps and samples_info
398
354
  for sample_idx, fm in enumerate(fmaps[1:], start=1):
@@ -422,33 +378,17 @@ def plot_alignment(
422
378
  max_inty = max(inty_vals)
423
379
 
424
380
  # Get sample metadata from filtered samples_info
425
- if (
426
- hasattr(self, "samples_df")
427
- and self.samples_df is not None
428
- and not self.samples_df.is_empty()
429
- ):
381
+ if hasattr(self, "samples_df") and self.samples_df is not None and not self.samples_df.is_empty():
430
382
  # Use filtered samples_info if it exists from the filtering above
431
- if "samples_info" in locals() and sample_idx < len(samples_info):
432
- sample_name = samples_info.iloc[sample_idx].get(
433
- "sample_name",
434
- f"Sample {sample_idx}",
435
- )
436
- sample_uid = samples_info.iloc[sample_idx].get(
437
- "sample_uid",
438
- f"Sample_{sample_idx}_UID",
439
- )
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")
440
386
  else:
441
387
  # Fallback to original samples_df if filtered samples_info is not available
442
388
  all_samples_info = self.samples_df.to_pandas()
443
389
  if sample_idx < len(all_samples_info):
444
- sample_name = all_samples_info.iloc[sample_idx].get(
445
- "sample_name",
446
- f"Sample {sample_idx}",
447
- )
448
- sample_uid = all_samples_info.iloc[sample_idx].get(
449
- "sample_uid",
450
- f"Sample_{sample_idx}_UID",
451
- )
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")
452
392
  else:
453
393
  sample_name = f"Sample {sample_idx}"
454
394
  sample_uid = f"Sample_{sample_idx}_UID"
@@ -457,32 +397,28 @@ def plot_alignment(
457
397
  sample_uid = f"Sample_{sample_idx}_UID"
458
398
 
459
399
  for rt, mz, inty in zip(original_rt, mz_vals, inty_vals):
460
- before_data.append(
461
- {
462
- "rt": rt,
463
- "mz": mz,
464
- "inty": inty,
465
- "alpha": inty / max_inty,
466
- "sample_idx": sample_idx,
467
- "sample_name": sample_name,
468
- "sample_uid": sample_uid,
469
- "size": markersize,
470
- },
471
- )
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
+ })
472
410
 
473
411
  for rt, mz, inty in zip(aligned_rt, mz_vals, inty_vals):
474
- after_data.append(
475
- {
476
- "rt": rt,
477
- "mz": mz,
478
- "inty": inty,
479
- "alpha": inty / max_inty,
480
- "sample_idx": sample_idx,
481
- "sample_name": sample_name,
482
- "sample_uid": sample_uid,
483
- "size": markersize,
484
- },
485
- )
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
+ })
486
422
 
487
423
  else:
488
424
  # Use features_df
@@ -497,14 +433,12 @@ def plot_alignment(
497
433
  return
498
434
 
499
435
  if "rt_original" not in self.features_df.columns:
500
- self.logger.error(
501
- "Column 'rt_original' not found in features_df. Alignment may not have been performed.",
502
- )
436
+ self.logger.error("Column 'rt_original' not found in features_df. Alignment may not have been performed.")
503
437
  return
504
438
 
505
439
  # Use Polars instead of pandas
506
440
  features_df = self.features_df
507
-
441
+
508
442
  # Filter by selected samples if specified
509
443
  if sample_uids is not None:
510
444
  features_df = features_df.filter(pl.col("sample_uid").is_in(sample_uids))
@@ -512,9 +446,7 @@ def plot_alignment(
512
446
  self.logger.error("No features found for the selected samples.")
513
447
  return
514
448
 
515
- sample_col = (
516
- "sample_uid" if "sample_uid" in features_df.columns else "sample_name"
517
- )
449
+ sample_col = "sample_uid" if "sample_uid" in features_df.columns else "sample_name"
518
450
  if sample_col not in features_df.columns:
519
451
  self.logger.error("No sample identifier column found in features_df.")
520
452
  return
@@ -542,9 +474,7 @@ def plot_alignment(
542
474
  sample_uid = sample
543
475
 
544
476
  # Convert to dict for iteration - more efficient than row-by-row processing
545
- sample_dict = sample_data.select(
546
- ["rt_original", "rt", "mz", "inty"],
547
- ).to_dicts()
477
+ sample_dict = sample_data.select(["rt_original", "rt", "mz", "inty"]).to_dicts()
548
478
 
549
479
  for row_dict in sample_dict:
550
480
  rt_original = row_dict["rt_original"]
@@ -554,30 +484,26 @@ def plot_alignment(
554
484
  alpha = inty / max_inty
555
485
  size = markersize + 2 if sample_idx == 0 else markersize
556
486
 
557
- before_data.append(
558
- {
559
- "rt": rt_original,
560
- "mz": mz,
561
- "inty": inty,
562
- "alpha": alpha,
563
- "sample_idx": sample_idx,
564
- "sample_name": sample_name,
565
- "sample_uid": sample_uid,
566
- "size": size,
567
- },
568
- )
569
- after_data.append(
570
- {
571
- "rt": rt_current,
572
- "mz": mz,
573
- "inty": inty,
574
- "alpha": alpha,
575
- "sample_idx": sample_idx,
576
- "sample_name": sample_name,
577
- "sample_uid": sample_uid,
578
- "size": size,
579
- },
580
- )
487
+ before_data.append({
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,
496
+ })
497
+ after_data.append({
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,
506
+ })
581
507
 
582
508
  # Get sample colors from samples_df using sample indices
583
509
  # Extract unique sample information from the dictionaries we created
@@ -598,19 +524,14 @@ def plot_alignment(
598
524
  .select(["sample_uid", "sample_color"])
599
525
  .to_dict(as_series=False)
600
526
  )
601
- uid_to_color = dict(
602
- zip(sample_colors["sample_uid"], sample_colors["sample_color"]),
603
- )
527
+ uid_to_color = dict(zip(sample_colors["sample_uid"], sample_colors["sample_color"]))
604
528
  else:
605
529
  uid_to_color = {}
606
530
 
607
531
  # Create color map for sample indices
608
532
  color_map: dict[int, str] = {}
609
533
  for sample_idx, sample_uid in sample_idx_to_uid.items():
610
- color_map[sample_idx] = uid_to_color.get(
611
- sample_uid,
612
- "#1f77b4",
613
- ) # fallback to blue
534
+ color_map[sample_idx] = uid_to_color.get(sample_uid, "#1f77b4") # fallback to blue
614
535
 
615
536
  # Add sample_color to data dictionaries before creating DataFrames
616
537
  if before_data:
@@ -655,11 +576,7 @@ def plot_alignment(
655
576
  p2.min_border = 0
656
577
 
657
578
  # Get unique sample indices for iteration
658
- unique_samples = (
659
- sorted(list({item["sample_idx"] for item in before_data}))
660
- if before_data
661
- else []
662
- )
579
+ unique_samples = sorted(list({item["sample_idx"] for item in before_data})) if before_data else []
663
580
 
664
581
  renderers_before = []
665
582
  renderers_after = []
@@ -671,26 +588,12 @@ def plot_alignment(
671
588
 
672
589
  if not sb.empty:
673
590
  src = ColumnDataSource(sb)
674
- r = p1.scatter(
675
- "rt",
676
- "mz",
677
- size="size",
678
- color=color,
679
- alpha="alpha",
680
- source=src,
681
- )
591
+ r = p1.scatter("rt", "mz", size="size", color=color, alpha="alpha", source=src)
682
592
  renderers_before.append(r)
683
593
 
684
594
  if not sa.empty:
685
595
  src = ColumnDataSource(sa)
686
- r = p2.scatter(
687
- "rt",
688
- "mz",
689
- size="size",
690
- color=color,
691
- alpha="alpha",
692
- source=src,
693
- )
596
+ r = p2.scatter("rt", "mz", size="size", color=color, alpha="alpha", source=src)
694
597
  renderers_after.append(r)
695
598
 
696
599
  # Add hover tools
@@ -728,21 +631,14 @@ def plot_alignment(
728
631
  if filename is not None:
729
632
  # Convert relative paths to absolute paths using study folder as base
730
633
  import os
731
-
732
634
  if not os.path.isabs(filename):
733
635
  filename = os.path.join(self.folder, filename)
734
-
636
+
735
637
  # Convert to absolute path for logging
736
638
  abs_filename = os.path.abspath(filename)
737
-
639
+
738
640
  # Use isolated file saving
739
- _isolated_save_plot(
740
- layout,
741
- filename,
742
- abs_filename,
743
- self.logger,
744
- "Alignment Plot",
745
- )
641
+ _isolated_save_plot(layout, filename, abs_filename, self.logger, "Alignment Plot")
746
642
  else:
747
643
  # Show in notebook when no filename provided
748
644
  _isolated_show_notebook(layout)
@@ -789,13 +685,9 @@ def plot_consensus_2d(
789
685
 
790
686
  # Filter by mz_range and rt_range if provided
791
687
  if mz_range is not None:
792
- data = data.filter(
793
- (pl.col("mz") >= mz_range[0]) & (pl.col("mz") <= mz_range[1]),
794
- )
688
+ data = data.filter((pl.col("mz") >= mz_range[0]) & (pl.col("mz") <= mz_range[1]))
795
689
  if rt_range is not None:
796
- data = data.filter(
797
- (pl.col("rt") >= rt_range[0]) & (pl.col("rt") <= rt_range[1]),
798
- )
690
+ data = data.filter((pl.col("rt") >= rt_range[0]) & (pl.col("rt") <= rt_range[1]))
799
691
 
800
692
  if colorby not in data.columns:
801
693
  self.logger.error(f"Column {colorby} not found in consensus_df.")
@@ -809,25 +701,19 @@ def plot_consensus_2d(
809
701
  if sizeby in ["inty_mean"]:
810
702
  # use log10 of sizeby
811
703
  # Filter out empty or all-NA entries before applying np.log10
812
- data = data.with_columns(
813
- [
814
- pl.when(
815
- (pl.col(sizeby).is_not_null())
816
- & (pl.col(sizeby).is_finite())
817
- & (pl.col(sizeby) > 0),
818
- )
819
- .then((pl.col(sizeby).log10() * markersize / 12).pow(2))
820
- .otherwise(markersize)
821
- .alias("markersize"),
822
- ],
823
- )
704
+ data = data.with_columns([
705
+ pl.when(
706
+ (pl.col(sizeby).is_not_null()) & (pl.col(sizeby).is_finite()) & (pl.col(sizeby) > 0),
707
+ )
708
+ .then((pl.col(sizeby).log10() * markersize / 12).pow(2))
709
+ .otherwise(markersize)
710
+ .alias("markersize"),
711
+ ])
824
712
  else:
825
713
  max_size = data[sizeby].max()
826
- data = data.with_columns(
827
- [
828
- (pl.col(sizeby) / max_size * markersize).alias("markersize"),
829
- ],
830
- )
714
+ data = data.with_columns([
715
+ (pl.col(sizeby) / max_size * markersize).alias("markersize"),
716
+ ])
831
717
  else:
832
718
  data = data.with_columns([pl.lit(markersize).alias("markersize")])
833
719
  # sort by ascending colorby
@@ -835,22 +721,18 @@ def plot_consensus_2d(
835
721
  # convert consensus_id to string - check if column exists
836
722
  if "consensus_id" in data.columns:
837
723
  # Handle Object dtype by converting to string first
838
- data = data.with_columns(
839
- [
840
- pl.col("consensus_id")
841
- .map_elements(
842
- lambda x: str(x) if x is not None else None,
843
- return_dtype=pl.Utf8,
844
- )
845
- .alias("consensus_id"),
846
- ],
847
- )
724
+ data = data.with_columns([
725
+ pl.col("consensus_id")
726
+ .map_elements(
727
+ lambda x: str(x) if x is not None else None,
728
+ return_dtype=pl.Utf8,
729
+ )
730
+ .alias("consensus_id"),
731
+ ])
848
732
  elif "consensus_uid" in data.columns:
849
- data = data.with_columns(
850
- [
851
- pl.col("consensus_uid").cast(pl.Utf8).alias("consensus_id"),
852
- ],
853
- )
733
+ data = data.with_columns([
734
+ pl.col("consensus_uid").cast(pl.Utf8).alias("consensus_id"),
735
+ ])
854
736
 
855
737
  if cmap is None:
856
738
  cmap = "viridis"
@@ -864,6 +746,7 @@ def plot_consensus_2d(
864
746
  from bokeh.models import ColumnDataSource
865
747
  from bokeh.models import HoverTool
866
748
  from bokeh.models import LinearColorMapper
749
+ from bokeh.io.export import export_png
867
750
 
868
751
  try:
869
752
  from bokeh.models import ColorBar # type: ignore[attr-defined]
@@ -911,9 +794,7 @@ def plot_consensus_2d(
911
794
  palette = [mcolors.rgb2hex(color) for color in colors]
912
795
  except (AttributeError, ValueError, TypeError) as e:
913
796
  # Fallback to viridis if cmap interpretation fails
914
- self.logger.warning(
915
- f"Could not interpret colormap '{cmap}': {e}, falling back to viridis",
916
- )
797
+ self.logger.warning(f"Could not interpret colormap '{cmap}': {e}, falling back to viridis")
917
798
  palette = viridis(256)
918
799
 
919
800
  color_mapper = LinearColorMapper(
@@ -981,13 +862,12 @@ def plot_consensus_2d(
981
862
  if filename is not None:
982
863
  # Convert relative paths to absolute paths using study folder as base
983
864
  import os
984
-
985
865
  if not os.path.isabs(filename):
986
866
  filename = os.path.join(self.folder, filename)
987
-
867
+
988
868
  # Convert to absolute path for logging
989
869
  abs_filename = os.path.abspath(filename)
990
-
870
+
991
871
  # Use isolated file saving
992
872
  _isolated_save_plot(p, filename, abs_filename, self.logger, "Consensus 2D Plot")
993
873
  else:
@@ -1033,7 +913,8 @@ def plot_samples_2d(
1033
913
  """
1034
914
 
1035
915
  # Local bokeh imports to avoid heavy top-level dependency
1036
- from bokeh.plotting import figure
916
+ from bokeh.plotting import figure, show, output_file
917
+ from bokeh.io.export import export_png
1037
918
  from bokeh.models import ColumnDataSource, HoverTool
1038
919
 
1039
920
  sample_uids = self._get_sample_uids(samples)
@@ -1063,13 +944,9 @@ def plot_samples_2d(
1063
944
 
1064
945
  # Filter by mz_range and rt_range if provided
1065
946
  if mz_range is not None:
1066
- features_batch = features_batch.filter(
1067
- (pl.col("mz") >= mz_range[0]) & (pl.col("mz") <= mz_range[1]),
1068
- )
947
+ features_batch = features_batch.filter((pl.col("mz") >= mz_range[0]) & (pl.col("mz") <= mz_range[1]))
1069
948
  if rt_range is not None:
1070
- features_batch = features_batch.filter(
1071
- (pl.col("rt") >= rt_range[0]) & (pl.col("rt") <= rt_range[1]),
1072
- )
949
+ features_batch = features_batch.filter((pl.col("rt") >= rt_range[0]) & (pl.col("rt") <= rt_range[1]))
1073
950
 
1074
951
  if features_batch.is_empty():
1075
952
  self.logger.error("No features found for the selected samples.")
@@ -1165,11 +1042,7 @@ def plot_samples_2d(
1165
1042
  "inty": sample_data["inty"].values,
1166
1043
  "alpha": sample_data["alpha"].values,
1167
1044
  "sample": np.full(len(sample_data), sample_name, dtype=object),
1168
- "sample_color": np.full(
1169
- len(sample_data),
1170
- color_values[uid],
1171
- dtype=object,
1172
- ),
1045
+ "sample_color": np.full(len(sample_data), color_values[uid], dtype=object),
1173
1046
  },
1174
1047
  )
1175
1048
 
@@ -1218,18 +1091,17 @@ def plot_samples_2d(
1218
1091
  # Only set legend properties if a legend was actually created to avoid Bokeh warnings
1219
1092
  if getattr(p, "legend", None) and len(p.legend) > 0:
1220
1093
  p.legend.visible = False
1221
-
1094
+
1222
1095
  # Apply consistent save/display behavior
1223
1096
  if filename is not None:
1224
1097
  # Convert relative paths to absolute paths using study folder as base
1225
1098
  import os
1226
-
1227
1099
  if not os.path.isabs(filename):
1228
1100
  filename = os.path.join(self.folder, filename)
1229
-
1101
+
1230
1102
  # Convert to absolute path for logging
1231
1103
  abs_filename = os.path.abspath(filename)
1232
-
1104
+
1233
1105
  # Use isolated file saving
1234
1106
  _isolated_save_plot(p, filename, abs_filename, self.logger, "Samples 2D Plot")
1235
1107
  else:
@@ -1257,9 +1129,10 @@ def plot_bpc(
1257
1129
  If False (default), return current/aligned RTs.
1258
1130
  """
1259
1131
  # Local imports to avoid heavy top-level deps / circular imports
1260
- from bokeh.plotting import figure
1132
+ from bokeh.plotting import figure, show, output_file
1261
1133
  from bokeh.models import ColumnDataSource, HoverTool
1262
- from master.study.helpers import get_bpc
1134
+ from bokeh.io.export import export_png
1135
+ from masster.study.helpers import get_bpc
1263
1136
 
1264
1137
  sample_uids = self._get_sample_uids(samples)
1265
1138
  if not sample_uids:
@@ -1294,12 +1167,7 @@ def plot_bpc(
1294
1167
  except Exception:
1295
1168
  continue
1296
1169
 
1297
- p = figure(
1298
- width=width,
1299
- height=height,
1300
- title=plot_title,
1301
- tools="pan,wheel_zoom,box_zoom,reset,save",
1302
- )
1170
+ p = figure(width=width, height=height, title=plot_title, tools="pan,wheel_zoom,box_zoom,reset,save")
1303
1171
  p.xaxis.axis_label = f"Retention Time ({rt_unit})"
1304
1172
  p.yaxis.axis_label = "Intensity"
1305
1173
 
@@ -1371,22 +1239,10 @@ def plot_bpc(
1371
1239
  f"Processing BPC for sample_uid={uid}, sample_name={sample_name}, rt_len={rt.size}, color={color}",
1372
1240
  )
1373
1241
 
1374
- data = {
1375
- "rt": rt,
1376
- "inty": inty,
1377
- "sample": [sample_name] * len(rt),
1378
- "sample_color": [color] * len(rt),
1379
- }
1242
+ data = {"rt": rt, "inty": inty, "sample": [sample_name] * len(rt), "sample_color": [color] * len(rt)}
1380
1243
  src = ColumnDataSource(data)
1381
1244
 
1382
- r_line = p.line(
1383
- "rt",
1384
- "inty",
1385
- source=src,
1386
- line_width=1,
1387
- color=color,
1388
- legend_label=str(sample_name),
1389
- )
1245
+ r_line = p.line("rt", "inty", source=src, line_width=1, color=color, legend_label=str(sample_name))
1390
1246
  r_points = p.scatter("rt", "inty", source=src, size=2, color=color, alpha=0.6)
1391
1247
  renderers.append(r_line)
1392
1248
 
@@ -1413,13 +1269,12 @@ def plot_bpc(
1413
1269
  if filename is not None:
1414
1270
  # Convert relative paths to absolute paths using study folder as base
1415
1271
  import os
1416
-
1417
1272
  if not os.path.isabs(filename):
1418
1273
  filename = os.path.join(self.folder, filename)
1419
-
1274
+
1420
1275
  # Convert to absolute path for logging
1421
1276
  abs_filename = os.path.abspath(filename)
1422
-
1277
+
1423
1278
  # Use isolated file saving
1424
1279
  _isolated_save_plot(p, filename, abs_filename, self.logger, "BPC Plot")
1425
1280
  else:
@@ -1451,9 +1306,10 @@ def plot_eic(
1451
1306
  mz_tol: m/z tolerance in Da. If None, uses study.parameters.eic_mz_tol as default.
1452
1307
  """
1453
1308
  # Local imports to avoid heavy top-level deps / circular imports
1454
- from bokeh.plotting import figure
1309
+ from bokeh.plotting import figure, show, output_file
1455
1310
  from bokeh.models import ColumnDataSource, HoverTool
1456
- from master.study.helpers import get_eic
1311
+ from bokeh.io.export import export_png
1312
+ from masster.study.helpers import get_eic
1457
1313
 
1458
1314
  # Use study's eic_mz_tol parameter as default if not provided
1459
1315
  if mz_tol is None:
@@ -1489,12 +1345,7 @@ def plot_eic(
1489
1345
  except Exception:
1490
1346
  continue
1491
1347
 
1492
- p = figure(
1493
- width=width,
1494
- height=height,
1495
- title=plot_title,
1496
- tools="pan,wheel_zoom,box_zoom,reset,save",
1497
- )
1348
+ p = figure(width=width, height=height, title=plot_title, tools="pan,wheel_zoom,box_zoom,reset,save")
1498
1349
  p.xaxis.axis_label = f"Retention Time ({rt_unit})"
1499
1350
  p.yaxis.axis_label = "Intensity"
1500
1351
 
@@ -1560,22 +1411,10 @@ def plot_eic(
1560
1411
 
1561
1412
  color = color_map.get(uid, "#000000")
1562
1413
 
1563
- data = {
1564
- "rt": rt,
1565
- "inty": inty,
1566
- "sample": [sample_name] * len(rt),
1567
- "sample_color": [color] * len(rt),
1568
- }
1414
+ data = {"rt": rt, "inty": inty, "sample": [sample_name] * len(rt), "sample_color": [color] * len(rt)}
1569
1415
  src = ColumnDataSource(data)
1570
1416
 
1571
- r_line = p.line(
1572
- "rt",
1573
- "inty",
1574
- source=src,
1575
- line_width=1,
1576
- color=color,
1577
- legend_label=str(sample_name),
1578
- )
1417
+ r_line = p.line("rt", "inty", source=src, line_width=1, color=color, legend_label=str(sample_name))
1579
1418
  p.scatter("rt", "inty", source=src, size=2, color=color, alpha=0.6)
1580
1419
  renderers.append(r_line)
1581
1420
 
@@ -1601,13 +1440,12 @@ def plot_eic(
1601
1440
  if filename is not None:
1602
1441
  # Convert relative paths to absolute paths using study folder as base
1603
1442
  import os
1604
-
1605
1443
  if not os.path.isabs(filename):
1606
1444
  filename = os.path.join(self.folder, filename)
1607
-
1445
+
1608
1446
  # Convert to absolute path for logging
1609
1447
  abs_filename = os.path.abspath(filename)
1610
-
1448
+
1611
1449
  # Use isolated file saving
1612
1450
  _isolated_save_plot(p, filename, abs_filename, self.logger, "EIC Plot")
1613
1451
  else:
@@ -1630,7 +1468,7 @@ def plot_rt_correction(
1630
1468
 
1631
1469
  This uses the same color mapping as `plot_bpc` so curves for the same samples match.
1632
1470
  """
1633
- from bokeh.plotting import figure
1471
+ from bokeh.plotting import figure, show, output_file
1634
1472
  from bokeh.models import ColumnDataSource, HoverTool
1635
1473
  import numpy as _np
1636
1474
 
@@ -1640,9 +1478,7 @@ def plot_rt_correction(
1640
1478
  return
1641
1479
 
1642
1480
  if "rt_original" not in self.features_df.columns:
1643
- self.logger.error(
1644
- "Column 'rt_original' not found in features_df. Alignment/backup RTs missing.",
1645
- )
1481
+ self.logger.error("Column 'rt_original' not found in features_df. Alignment/backup RTs missing.")
1646
1482
  return
1647
1483
 
1648
1484
  sample_uids = self._get_sample_uids(samples)
@@ -1661,12 +1497,7 @@ def plot_rt_correction(
1661
1497
  # For RT correction plots, default to "s" since we're working with features_df directly
1662
1498
  rt_unit = "s"
1663
1499
 
1664
- p = figure(
1665
- width=width,
1666
- height=height,
1667
- title=title or "RT correction",
1668
- tools="pan,wheel_zoom,box_zoom,reset,save",
1669
- )
1500
+ p = figure(width=width, height=height, title=title or "RT correction", tools="pan,wheel_zoom,box_zoom,reset,save")
1670
1501
  p.xaxis.axis_label = f"Retention Time ({rt_unit})"
1671
1502
  p.yaxis.axis_label = "RT - RT_original (s)"
1672
1503
 
@@ -1688,9 +1519,7 @@ def plot_rt_correction(
1688
1519
  elif "sample_name" in self.features_df.columns:
1689
1520
  sample_feats = self.features_df.filter(pl.col("sample_name") == uid)
1690
1521
  else:
1691
- self.logger.debug(
1692
- "No sample identifier column in features_df; skipping sample filtering",
1693
- )
1522
+ self.logger.debug("No sample identifier column in features_df; skipping sample filtering")
1694
1523
  continue
1695
1524
  except Exception as e:
1696
1525
  self.logger.debug(f"Error filtering features for sample {uid}: {e}")
@@ -1734,12 +1563,7 @@ def plot_rt_correction(
1734
1563
 
1735
1564
  color = color_map.get(uid, "#000000")
1736
1565
 
1737
- data = {
1738
- "rt": rt,
1739
- "delta": delta,
1740
- "sample": [sample_name] * len(rt),
1741
- "sample_color": [color] * len(rt),
1742
- }
1566
+ data = {"rt": rt, "delta": delta, "sample": [sample_name] * len(rt), "sample_color": [color] * len(rt)}
1743
1567
  src = ColumnDataSource(data)
1744
1568
 
1745
1569
  r_line = p.line("rt", "delta", source=src, line_width=1, color=color)
@@ -1769,21 +1593,14 @@ def plot_rt_correction(
1769
1593
  if filename is not None:
1770
1594
  # Convert relative paths to absolute paths using study folder as base
1771
1595
  import os
1772
-
1773
1596
  if not os.path.isabs(filename):
1774
1597
  filename = os.path.join(self.folder, filename)
1775
-
1598
+
1776
1599
  # Convert to absolute path for logging
1777
1600
  abs_filename = os.path.abspath(filename)
1778
-
1601
+
1779
1602
  # Use isolated file saving
1780
- _isolated_save_plot(
1781
- p,
1782
- filename,
1783
- abs_filename,
1784
- self.logger,
1785
- "RT Correction Plot",
1786
- )
1603
+ _isolated_save_plot(p, filename, abs_filename, self.logger, "RT Correction Plot")
1787
1604
  else:
1788
1605
  # Show in notebook when no filename provided
1789
1606
  _isolated_show_notebook(p)
@@ -1817,18 +1634,10 @@ def plot_chrom(
1817
1634
  return
1818
1635
 
1819
1636
  # Create color mapping by getting sample_color for each sample_name
1820
- samples_info = self.samples_df.select(
1821
- ["sample_name", "sample_color", "sample_uid"],
1822
- ).to_dict(as_series=False)
1823
- sample_name_to_color = dict(
1824
- zip(samples_info["sample_name"], samples_info["sample_color"]),
1825
- )
1826
- sample_name_to_uid = dict(
1827
- zip(samples_info["sample_name"], samples_info["sample_uid"]),
1828
- )
1829
- color_map = {
1830
- name: sample_name_to_color.get(name, "#1f77b4") for name in sample_names
1831
- } # fallback to blue
1637
+ samples_info = self.samples_df.select(["sample_name", "sample_color", "sample_uid"]).to_dict(as_series=False)
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"]))
1640
+ color_map = {name: sample_name_to_color.get(name, "#1f77b4") for name in sample_names} # fallback to blue
1832
1641
 
1833
1642
  plots = []
1834
1643
  self.logger.info(f"Plotting {chroms.shape[0]} chromatograms...")
@@ -1883,26 +1692,20 @@ def plot_chrom(
1883
1692
  sorted_indices = np.argsort(rt)
1884
1693
  rt = rt[sorted_indices]
1885
1694
  inty = inty[sorted_indices]
1886
-
1695
+
1887
1696
  # Get sample uid for this sample name
1888
1697
  sample_uid = sample_name_to_uid.get(sample, None)
1889
1698
  sample_color = color_map.get(sample, "#1f77b4")
1890
-
1699
+
1891
1700
  # Create arrays with sample information for hover tooltips
1892
1701
  sample_names_array = [sample] * len(rt)
1893
1702
  sample_uids_array = [sample_uid] * len(rt)
1894
1703
  sample_colors_array = [sample_color] * len(rt)
1895
-
1704
+
1896
1705
  curve = hv.Curve(
1897
- (
1898
- rt,
1899
- inty,
1900
- sample_names_array,
1901
- sample_uids_array,
1902
- sample_colors_array,
1903
- ),
1904
- kdims=["RT"],
1905
- vdims=["inty", "sample_name", "sample_uid", "sample_color"],
1706
+ (rt, inty, sample_names_array, sample_uids_array, sample_colors_array),
1707
+ kdims=["RT"],
1708
+ vdims=["inty", "sample_name", "sample_uid", "sample_color"]
1906
1709
  ).opts(
1907
1710
  color=color_map[sample],
1908
1711
  line_width=1,
@@ -1912,8 +1715,8 @@ def plot_chrom(
1912
1715
  ("Intensity", "@inty{0,0}"),
1913
1716
  ("Sample Name", "@sample_name"),
1914
1717
  ("Sample UID", "@sample_uid"),
1915
- ("Sample Color", "$color[swatch]:sample_color"),
1916
- ],
1718
+ ("Sample Color", "$color[swatch]:sample_color")
1719
+ ]
1917
1720
  )
1918
1721
  curves.append(curve)
1919
1722
 
@@ -1972,26 +1775,19 @@ def plot_chrom(
1972
1775
  # stack vertically.
1973
1776
  # Stack all plots vertically in a Panel column
1974
1777
  layout = panel.Column(*[panel.panel(plot) for plot in plots])
1975
-
1778
+
1976
1779
  # Apply consistent save/display behavior
1977
1780
  if filename is not None:
1978
1781
  # Convert relative paths to absolute paths using study folder as base
1979
1782
  import os
1980
-
1981
1783
  if not os.path.isabs(filename):
1982
1784
  filename = os.path.join(self.folder, filename)
1983
-
1785
+
1984
1786
  # Convert to absolute path for logging
1985
1787
  abs_filename = os.path.abspath(filename)
1986
-
1788
+
1987
1789
  # Use isolated Panel saving
1988
- _isolated_save_panel_plot(
1989
- panel.panel(layout),
1990
- filename,
1991
- abs_filename,
1992
- self.logger,
1993
- "Chromatogram Plot",
1994
- )
1790
+ _isolated_save_panel_plot(panel.panel(layout), filename, abs_filename, self.logger, "Chromatogram Plot")
1995
1791
  else:
1996
1792
  # Show in notebook when no filename provided
1997
1793
  # Convert Panel layout to Bokeh layout for consistent isolated display
@@ -2025,13 +1821,11 @@ def plot_consensus_stats(
2025
1821
  """
2026
1822
  from bokeh.layouts import gridplot
2027
1823
  from bokeh.models import ColumnDataSource, HoverTool
2028
- from bokeh.plotting import figure
1824
+ from bokeh.plotting import figure, show, output_file
2029
1825
 
2030
1826
  # Check if consensus_df exists and has data
2031
1827
  if self.consensus_df is None or self.consensus_df.is_empty():
2032
- self.logger.error(
2033
- "No consensus data available. Run merge/find_consensus first.",
2034
- )
1828
+ self.logger.error("No consensus data available. Run merge/find_consensus first.")
2035
1829
  return
2036
1830
 
2037
1831
  # Define the columns to plot
@@ -2062,9 +1856,7 @@ def plot_consensus_stats(
2062
1856
  final_columns = [col for col in columns if col in data_df.columns]
2063
1857
 
2064
1858
  if len(final_columns) < 2:
2065
- self.logger.error(
2066
- f"Need at least 2 columns for SPLOM. Available: {final_columns}",
2067
- )
1859
+ self.logger.error(f"Need at least 2 columns for SPLOM. Available: {final_columns}")
2068
1860
  return
2069
1861
 
2070
1862
  self.logger.debug(f"Creating SPLOM with columns: {final_columns}")
@@ -2237,21 +2029,14 @@ def plot_consensus_stats(
2237
2029
  if filename is not None:
2238
2030
  # Convert relative paths to absolute paths using study folder as base
2239
2031
  import os
2240
-
2241
2032
  if not os.path.isabs(filename):
2242
2033
  filename = os.path.join(self.folder, filename)
2243
-
2034
+
2244
2035
  # Convert to absolute path for logging
2245
2036
  abs_filename = os.path.abspath(filename)
2246
-
2037
+
2247
2038
  # Use isolated file saving
2248
- _isolated_save_plot(
2249
- grid,
2250
- filename,
2251
- abs_filename,
2252
- self.logger,
2253
- "Consensus Stats Plot",
2254
- )
2039
+ _isolated_save_plot(grid, filename, abs_filename, self.logger, "Consensus Stats Plot")
2255
2040
  else:
2256
2041
  # Show in notebook when no filename provided
2257
2042
  _isolated_show_notebook(grid)
@@ -2283,9 +2068,10 @@ def plot_pca(
2283
2068
  title (str): Plot title (default: "PCA of Consensus Matrix")
2284
2069
  """
2285
2070
  from bokeh.models import ColumnDataSource, HoverTool, ColorBar, LinearColorMapper
2286
- from bokeh.plotting import figure
2071
+ from bokeh.plotting import figure, show, output_file
2287
2072
  from bokeh.palettes import Category20, viridis
2288
2073
  from bokeh.transform import factor_cmap
2074
+ from bokeh.io.export import export_png
2289
2075
  from sklearn.decomposition import PCA
2290
2076
  from sklearn.preprocessing import StandardScaler
2291
2077
  import pandas as pd
@@ -2300,32 +2086,25 @@ def plot_pca(
2300
2086
  return
2301
2087
 
2302
2088
  if consensus_matrix is None or consensus_matrix.shape[0] == 0:
2303
- self.logger.error(
2304
- "No consensus matrix available. Run merge/find_consensus first.",
2305
- )
2089
+ self.logger.error("No consensus matrix available. Run merge/find_consensus first.")
2306
2090
  return
2307
2091
 
2308
2092
  if samples_df is None or samples_df.is_empty():
2309
2093
  self.logger.error("No samples dataframe available.")
2310
2094
  return
2311
2095
 
2312
- self.logger.debug(
2313
- f"Performing PCA on consensus matrix with shape: {consensus_matrix.shape}",
2314
- )
2096
+ self.logger.debug(f"Performing PCA on consensus matrix with shape: {consensus_matrix.shape}")
2315
2097
 
2316
2098
  # Extract only the sample columns (exclude consensus_uid column)
2317
2099
  sample_cols = [col for col in consensus_matrix.columns if col != "consensus_uid"]
2318
-
2100
+
2319
2101
  # Convert consensus matrix to numpy, excluding the consensus_uid column
2320
2102
  if hasattr(consensus_matrix, "select"):
2321
2103
  # Polars DataFrame
2322
2104
  matrix_data = consensus_matrix.select(sample_cols).to_numpy()
2323
2105
  else:
2324
2106
  # Pandas DataFrame or other - drop consensus_uid column
2325
- matrix_sample_data = consensus_matrix.drop(
2326
- columns=["consensus_uid"],
2327
- errors="ignore",
2328
- )
2107
+ matrix_sample_data = consensus_matrix.drop(columns=["consensus_uid"], errors="ignore")
2329
2108
  if hasattr(matrix_sample_data, "values"):
2330
2109
  matrix_data = matrix_sample_data.values
2331
2110
  elif hasattr(matrix_sample_data, "to_numpy"):
@@ -2356,12 +2135,10 @@ def plot_pca(
2356
2135
  samples_pd = samples_df.to_pandas()
2357
2136
 
2358
2137
  # Create dataframe with PCA results and sample information
2359
- pca_df = pd.DataFrame(
2360
- {
2361
- "PC1": pca_result[:, 0],
2362
- "PC2": pca_result[:, 1] if n_components > 1 else np.zeros(len(pca_result)),
2363
- },
2364
- )
2138
+ pca_df = pd.DataFrame({
2139
+ "PC1": pca_result[:, 0],
2140
+ "PC2": pca_result[:, 1] if n_components > 1 else np.zeros(len(pca_result)),
2141
+ })
2365
2142
 
2366
2143
  # Add sample information to PCA dataframe
2367
2144
  if len(samples_pd) == len(pca_df):
@@ -2445,31 +2222,21 @@ def plot_pca(
2445
2222
  # Get colors from samples_df based on the identifier
2446
2223
  if id_col == "sample_uid":
2447
2224
  sample_colors = (
2448
- self.samples_df.filter(
2449
- pl.col("sample_uid").is_in(pca_df[id_col].unique()),
2450
- )
2225
+ self.samples_df.filter(pl.col("sample_uid").is_in(pca_df[id_col].unique()))
2451
2226
  .select(["sample_uid", "sample_color"])
2452
2227
  .to_dict(as_series=False)
2453
2228
  )
2454
- color_map = dict(
2455
- zip(sample_colors["sample_uid"], sample_colors["sample_color"]),
2456
- )
2229
+ color_map = dict(zip(sample_colors["sample_uid"], sample_colors["sample_color"]))
2457
2230
  else: # sample_name
2458
2231
  sample_colors = (
2459
- self.samples_df.filter(
2460
- pl.col("sample_name").is_in(pca_df[id_col].unique()),
2461
- )
2232
+ self.samples_df.filter(pl.col("sample_name").is_in(pca_df[id_col].unique()))
2462
2233
  .select(["sample_name", "sample_color"])
2463
2234
  .to_dict(as_series=False)
2464
2235
  )
2465
- color_map = dict(
2466
- zip(sample_colors["sample_name"], sample_colors["sample_color"]),
2467
- )
2236
+ color_map = dict(zip(sample_colors["sample_name"], sample_colors["sample_color"]))
2468
2237
 
2469
2238
  # Map colors into dataframe
2470
- pca_df["color"] = [
2471
- color_map.get(x, "#1f77b4") for x in pca_df[id_col]
2472
- ] # fallback to blue
2239
+ pca_df["color"] = [color_map.get(x, "#1f77b4") for x in pca_df[id_col]] # fallback to blue
2473
2240
  # Update the ColumnDataSource with new color column
2474
2241
  source = ColumnDataSource(pca_df)
2475
2242
  scatter = p.scatter(
@@ -2494,17 +2261,7 @@ def plot_pca(
2494
2261
  tooltip_list = []
2495
2262
 
2496
2263
  # Columns to exclude from tooltips (file paths and internal/plot fields)
2497
- excluded_cols = {
2498
- "file_source",
2499
- "file_path",
2500
- "sample_path",
2501
- "map_id",
2502
- "PC1",
2503
- "PC2",
2504
- "ms1",
2505
- "ms2",
2506
- "size",
2507
- }
2264
+ excluded_cols = {"file_source", "file_path", "sample_path", "map_id", "PC1", "PC2", "ms1", "ms2", "size"}
2508
2265
 
2509
2266
  # Add all sample dataframe columns to tooltips, skipping excluded ones
2510
2267
  for col in samples_pd.columns:
@@ -2536,13 +2293,12 @@ def plot_pca(
2536
2293
  if filename is not None:
2537
2294
  # Convert relative paths to absolute paths using study folder as base
2538
2295
  import os
2539
-
2540
2296
  if not os.path.isabs(filename):
2541
2297
  filename = os.path.join(self.folder, filename)
2542
-
2298
+
2543
2299
  # Convert to absolute path for logging
2544
2300
  abs_filename = os.path.abspath(filename)
2545
-
2301
+
2546
2302
  # Use isolated file saving
2547
2303
  _isolated_save_plot(p, filename, abs_filename, self.logger, "PCA Plot")
2548
2304
  else:
@@ -2566,9 +2322,10 @@ def plot_tic(
2566
2322
  Parameters and behavior mirror `plot_bpc` but use per-sample TICs (get_tic).
2567
2323
  """
2568
2324
  # Local imports to avoid heavy top-level deps / circular imports
2569
- from bokeh.plotting import figure
2325
+ from bokeh.plotting import figure, show, output_file
2570
2326
  from bokeh.models import ColumnDataSource, HoverTool
2571
- from master.study.helpers import get_tic
2327
+ from bokeh.io.export import export_png
2328
+ from masster.study.helpers import get_tic
2572
2329
 
2573
2330
  sample_uids = self._get_sample_uids(samples)
2574
2331
  if not sample_uids:
@@ -2596,12 +2353,7 @@ def plot_tic(
2596
2353
  except Exception:
2597
2354
  continue
2598
2355
 
2599
- p = figure(
2600
- width=width,
2601
- height=height,
2602
- title=plot_title,
2603
- tools="pan,wheel_zoom,box_zoom,reset,save",
2604
- )
2356
+ p = figure(width=width, height=height, title=plot_title, tools="pan,wheel_zoom,box_zoom,reset,save")
2605
2357
  p.xaxis.axis_label = f"Retention Time ({rt_unit})"
2606
2358
  p.yaxis.axis_label = "Intensity"
2607
2359
 
@@ -2665,22 +2417,10 @@ def plot_tic(
2665
2417
 
2666
2418
  color = color_map.get(uid, "#000000")
2667
2419
 
2668
- data = {
2669
- "rt": rt,
2670
- "inty": inty,
2671
- "sample": [sample_name] * len(rt),
2672
- "sample_color": [color] * len(rt),
2673
- }
2420
+ data = {"rt": rt, "inty": inty, "sample": [sample_name] * len(rt), "sample_color": [color] * len(rt)}
2674
2421
  src = ColumnDataSource(data)
2675
2422
 
2676
- r_line = p.line(
2677
- "rt",
2678
- "inty",
2679
- source=src,
2680
- line_width=1,
2681
- color=color,
2682
- legend_label=str(sample_name),
2683
- )
2423
+ r_line = p.line("rt", "inty", source=src, line_width=1, color=color, legend_label=str(sample_name))
2684
2424
  p.scatter("rt", "inty", source=src, size=2, color=color, alpha=0.6)
2685
2425
  renderers.append(r_line)
2686
2426
 
@@ -2707,13 +2447,12 @@ def plot_tic(
2707
2447
  if filename is not None:
2708
2448
  # Convert relative paths to absolute paths using study folder as base
2709
2449
  import os
2710
-
2711
2450
  if not os.path.isabs(filename):
2712
2451
  filename = os.path.join(self.folder, filename)
2713
-
2452
+
2714
2453
  # Convert to absolute path for logging
2715
2454
  abs_filename = os.path.abspath(filename)
2716
-
2455
+
2717
2456
  # Use isolated file saving
2718
2457
  _isolated_save_plot(p, filename, abs_filename, self.logger, "TIC Plot")
2719
2458
  else: