masster 0.3.10__py3-none-any.whl → 0.3.12__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.

masster/sample/plot.py CHANGED
@@ -56,6 +56,7 @@ from bokeh.models import HoverTool
56
56
  from holoviews import dim
57
57
  from holoviews.plotting.util import process_cmap
58
58
  from matplotlib.colors import rgb2hex
59
+ from masster.chromatogram import Chromatogram
59
60
 
60
61
  # Parameters removed - using hardcoded defaults
61
62
 
@@ -66,49 +67,51 @@ hv.extension("bokeh")
66
67
  def _is_notebook_environment():
67
68
  """
68
69
  Detect if code is running in a notebook environment (Jupyter, JupyterLab, or Marimo).
69
-
70
+
70
71
  Returns:
71
72
  bool: True if running in a notebook, False otherwise
72
73
  """
73
74
  try:
74
75
  # Check for Jupyter/JupyterLab
75
76
  from IPython import get_ipython
77
+
76
78
  if get_ipython() is not None:
77
79
  # Check if we're in a notebook context
78
80
  shell = get_ipython().__class__.__name__
79
- if shell in ['ZMQInteractiveShell', 'Shell']: # Jupyter notebook/lab
81
+ if shell in ["ZMQInteractiveShell", "Shell"]: # Jupyter notebook/lab
80
82
  return True
81
-
83
+
82
84
  # Check for Marimo
83
85
  import sys
84
- if 'marimo' in sys.modules:
86
+
87
+ if "marimo" in sys.modules:
85
88
  return True
86
-
89
+
87
90
  # Additional check for notebook environments
88
- if hasattr(__builtins__, '__IPYTHON__') or hasattr(__builtins__, '_ih'):
91
+ if hasattr(__builtins__, "__IPYTHON__") or hasattr(__builtins__, "_ih"):
89
92
  return True
90
-
93
+
91
94
  except ImportError:
92
95
  pass
93
-
96
+
94
97
  return False
95
98
 
96
99
 
97
100
  def _display_plot(plot_object, layout=None):
98
101
  """
99
102
  Display a plot object in the appropriate way based on the environment.
100
-
103
+
101
104
  Args:
102
105
  plot_object: The plot object to display (holoviews overlay, etc.)
103
106
  layout: Optional panel layout object
104
-
107
+
105
108
  Returns:
106
109
  The layout object if in notebook environment, None otherwise
107
110
  """
108
111
  if _is_notebook_environment():
109
112
  # Display inline in notebook
110
113
  try:
111
- # For Jupyter notebooks, just return the plot object -
114
+ # For Jupyter notebooks, just return the plot object -
112
115
  # holoviews will handle the display automatically
113
116
  return plot_object
114
117
  except Exception:
@@ -395,17 +398,18 @@ def plot_2d(
395
398
  # Configure marker and size behavior based on size parameter
396
399
  use_dynamic_sizing = size.lower() in ["dyn", "dynamic"]
397
400
  use_slider_sizing = size.lower() == "slider"
398
-
401
+
399
402
  def dynamic_sizing_hook(plot, element):
400
403
  """Hook to convert size-based markers to radius-based for dynamic behavior"""
401
404
  try:
402
- if use_dynamic_sizing and hasattr(plot, 'state') and hasattr(plot.state, 'renderers'):
405
+ if use_dynamic_sizing and hasattr(plot, "state") and hasattr(plot.state, "renderers"):
403
406
  from bokeh.models import Circle
407
+
404
408
  for renderer in plot.state.renderers:
405
- if hasattr(renderer, 'glyph'):
409
+ if hasattr(renderer, "glyph"):
406
410
  glyph = renderer.glyph
407
411
  # Check if it's a circle/scatter glyph that we can convert
408
- if hasattr(glyph, 'size') and marker_type == "circle":
412
+ if hasattr(glyph, "size") and marker_type == "circle":
409
413
  # Create a new Circle glyph with radius instead of size
410
414
  new_glyph = Circle(
411
415
  x=glyph.x,
@@ -420,7 +424,7 @@ def plot_2d(
420
424
  except Exception:
421
425
  # Silently fail and use regular sizing if hook doesn't work
422
426
  pass
423
-
427
+
424
428
  if use_dynamic_sizing:
425
429
  # Dynamic sizing: use coordinate-based sizing that scales with zoom
426
430
  marker_type = "circle"
@@ -446,7 +450,7 @@ def plot_2d(
446
450
  size_2 = markersize
447
451
  base_radius = None # Not used in static mode
448
452
  hooks = []
449
-
453
+
450
454
  color_1 = "forestgreen"
451
455
  color_2 = "darkorange"
452
456
  if filename is not None:
@@ -519,14 +523,16 @@ def plot_2d(
519
523
  # find features with ms2_scans not None and iso==0
520
524
  features_df = feats[feats["ms2_scans"].notnull()]
521
525
  # Create feature points with proper sizing method
522
- feature_hover_1 = HoverTool(tooltips=[
523
- ("rt", "@rt"),
524
- ("m/z", "@mz{0.0000}"),
525
- ("feature_uid", "@feature_uid"),
526
- ("inty", "@inty"),
527
- ("quality", "@quality"),
528
- ("rt_delta", "@rt_delta"),
529
- ])
526
+ feature_hover_1 = HoverTool(
527
+ tooltips=[
528
+ ("rt", "@rt"),
529
+ ("m/z", "@mz{0.0000}"),
530
+ ("feature_uid", "@feature_uid"),
531
+ ("inty", "@inty"),
532
+ ("quality", "@quality"),
533
+ ("rt_delta", "@rt_delta"),
534
+ ],
535
+ )
530
536
  feature_points_1 = hv.Points(
531
537
  features_df,
532
538
  kdims=["rt", "mz"],
@@ -549,14 +555,16 @@ def plot_2d(
549
555
  )
550
556
  # find features without MS2 data
551
557
  features_df = feats[feats["ms2_scans"].isnull()]
552
- feature_hover_2 = HoverTool(tooltips=[
553
- ("rt", "@rt"),
554
- ("m/z", "@mz{0.0000}"),
555
- ("feature_uid", "@feature_uid"),
556
- ("inty", "@inty"),
557
- ("quality", "@quality"),
558
- ("rt_delta", "@rt_delta"),
559
- ])
558
+ feature_hover_2 = HoverTool(
559
+ tooltips=[
560
+ ("rt", "@rt"),
561
+ ("m/z", "@mz{0.0000}"),
562
+ ("feature_uid", "@feature_uid"),
563
+ ("inty", "@inty"),
564
+ ("quality", "@quality"),
565
+ ("rt_delta", "@rt_delta"),
566
+ ],
567
+ )
560
568
  feature_points_2 = hv.Points(
561
569
  features_df,
562
570
  kdims=["rt", "mz"],
@@ -583,16 +591,18 @@ def plot_2d(
583
591
  # Convert to pandas for plotting compatibility
584
592
  if hasattr(features_df, "to_pandas"):
585
593
  features_df = features_df.to_pandas()
586
- feature_hover_iso = HoverTool(tooltips=[
587
- ("rt", "@rt"),
588
- ("m/z", "@mz{0.0000}"),
589
- ("feature_uid", "@feature_uid"),
590
- ("inty", "@inty"),
591
- ("quality", "@quality"),
592
- ("rt_delta", "@rt_delta"),
593
- ("iso", "@iso"),
594
- ("iso_of", "@iso_of"),
595
- ])
594
+ feature_hover_iso = HoverTool(
595
+ tooltips=[
596
+ ("rt", "@rt"),
597
+ ("m/z", "@mz{0.0000}"),
598
+ ("feature_uid", "@feature_uid"),
599
+ ("inty", "@inty"),
600
+ ("quality", "@quality"),
601
+ ("rt_delta", "@rt_delta"),
602
+ ("iso", "@iso"),
603
+ ("iso_of", "@iso_of"),
604
+ ],
605
+ )
596
606
  feature_points_iso = hv.Points(
597
607
  features_df,
598
608
  kdims=["rt", "mz"],
@@ -623,13 +633,15 @@ def plot_2d(
623
633
  if len(ms2_orphan) > 0:
624
634
  # pandalize
625
635
  ms2 = ms2_orphan.to_pandas()
626
- ms2_hover_3 = HoverTool(tooltips=[
627
- ("rt", "@rt"),
628
- ("prec_mz", "@prec_mz{0.0000}"),
629
- ("index", "@index"),
630
- ("inty_tot", "@inty_tot"),
631
- ("bl", "@bl"),
632
- ])
636
+ ms2_hover_3 = HoverTool(
637
+ tooltips=[
638
+ ("rt", "@rt"),
639
+ ("prec_mz", "@prec_mz{0.0000}"),
640
+ ("index", "@index"),
641
+ ("inty_tot", "@inty_tot"),
642
+ ("bl", "@bl"),
643
+ ],
644
+ )
633
645
  feature_points_3 = hv.Points(
634
646
  ms2,
635
647
  kdims=["rt", "prec_mz"],
@@ -648,13 +660,15 @@ def plot_2d(
648
660
  if len(ms2_linked) > 0:
649
661
  # pandalize
650
662
  ms2 = ms2_linked.to_pandas()
651
- ms2_hover_4 = HoverTool(tooltips=[
652
- ("rt", "@rt"),
653
- ("prec_mz", "@prec_mz{0.0000}"),
654
- ("index", "@index"),
655
- ("inty_tot", "@inty_tot"),
656
- ("bl", "@bl"),
657
- ])
663
+ ms2_hover_4 = HoverTool(
664
+ tooltips=[
665
+ ("rt", "@rt"),
666
+ ("prec_mz", "@prec_mz{0.0000}"),
667
+ ("index", "@index"),
668
+ ("inty_tot", "@inty_tot"),
669
+ ("bl", "@bl"),
670
+ ],
671
+ )
658
672
  feature_points_4 = hv.Points(
659
673
  ms2,
660
674
  kdims=["rt", "prec_mz"],
@@ -688,17 +702,17 @@ def plot_2d(
688
702
  # For slider functionality, we need to work with the feature points directly
689
703
  # and not nest DynamicMaps. We'll create the slider using param and panel.
690
704
  import param
691
- import panel as pn
692
-
705
+ import panel as on
706
+
693
707
  class MarkerSizeController(param.Parameterized):
694
708
  size_slider = param.Number(default=markersize, bounds=(1, 20), step=0.5)
695
-
709
+
696
710
  controller = MarkerSizeController()
697
-
711
+
698
712
  # Create a function that generates just the feature overlays with different sizes
699
713
  def create_feature_overlay(size_val):
700
714
  feature_overlay = None
701
-
715
+
702
716
  if feature_points_4 is not None:
703
717
  updated_points_4 = feature_points_4.opts(size=size_val)
704
718
  feature_overlay = updated_points_4 if feature_overlay is None else feature_overlay * updated_points_4
@@ -713,22 +727,24 @@ def plot_2d(
713
727
  feature_overlay = updated_points_2 if feature_overlay is None else feature_overlay * updated_points_2
714
728
  if feature_points_iso is not None:
715
729
  updated_points_iso = feature_points_iso.opts(size=size_val)
716
- feature_overlay = updated_points_iso if feature_overlay is None else feature_overlay * updated_points_iso
717
-
730
+ feature_overlay = (
731
+ updated_points_iso if feature_overlay is None else feature_overlay * updated_points_iso
732
+ )
733
+
718
734
  # Combine with the static raster background
719
735
  if feature_overlay is not None:
720
736
  combined_overlay = raster * feature_overlay
721
737
  else:
722
738
  combined_overlay = raster
723
-
739
+
724
740
  if title is not None:
725
741
  combined_overlay = combined_overlay.opts(title=title)
726
-
742
+
727
743
  return combined_overlay
728
-
744
+
729
745
  # Create a horizontal control widget on top of the plot
730
746
  # Create the slider widget with explicit visibility
731
- size_slider = pn.widgets.FloatSlider(
747
+ size_slider = on.widgets.FloatSlider(
732
748
  name="Marker Size",
733
749
  start=1.0,
734
750
  end=20.0,
@@ -737,19 +753,19 @@ def plot_2d(
737
753
  width=300,
738
754
  height=40,
739
755
  margin=(5, 5),
740
- show_value=True
756
+ show_value=True,
741
757
  )
742
-
758
+
743
759
  # Create the slider widget row with clear styling
744
- slider_widget = pn.Row(
745
- pn.pane.HTML("<b>Marker Size Control:</b>", width=150, height=40, margin=(5, 10)),
760
+ slider_widget = on.Row(
761
+ on.pane.HTML("<b>Marker Size Control:</b>", width=150, height=40, margin=(5, 10)),
746
762
  size_slider,
747
763
  height=60,
748
- margin=10
764
+ margin=10,
749
765
  )
750
-
766
+
751
767
  # Create slider widget
752
- size_slider = pn.widgets.FloatSlider(
768
+ size_slider = on.widgets.FloatSlider(
753
769
  name="Marker Size",
754
770
  start=1.0,
755
771
  end=20.0,
@@ -758,18 +774,18 @@ def plot_2d(
758
774
  width=300,
759
775
  height=40,
760
776
  margin=(5, 5),
761
- show_value=True
777
+ show_value=True,
762
778
  )
763
-
764
- slider_widget = pn.Row(
765
- pn.pane.HTML("<b>Marker Size:</b>", width=100, height=40, margin=(5, 10)),
779
+
780
+ slider_widget = on.Row(
781
+ on.pane.HTML("<b>Marker Size:</b>", width=100, height=40, margin=(5, 10)),
766
782
  size_slider,
767
783
  height=60,
768
- margin=10
784
+ margin=10,
769
785
  )
770
-
786
+
771
787
  # Simple reactive plot - slider mode doesn't use dynamic rasterization
772
- @pn.depends(size_slider.param.value)
788
+ @on.depends(size_slider.param.value)
773
789
  def reactive_plot(size_val):
774
790
  overlay = create_feature_overlay(float(size_val))
775
791
  # Apply static rasterization for slider mode
@@ -779,19 +795,19 @@ def plot_2d(
779
795
  aggregator=ds.count(),
780
796
  width=raster_max_px,
781
797
  height=raster_max_px,
782
- dynamic=False # Static raster for slider mode
798
+ dynamic=False, # Static raster for slider mode
783
799
  ).opts(
784
- cnorm='eq_hist',
785
- tools=['hover'],
800
+ cnorm="eq_hist",
801
+ tools=["hover"],
786
802
  width=width,
787
- height=height
803
+ height=height,
788
804
  )
789
805
  else:
790
806
  return overlay
791
-
807
+
792
808
  # Create layout
793
- layout = pn.Column(slider_widget, reactive_plot, sizing_mode='stretch_width')
794
-
809
+ layout = on.Column(slider_widget, reactive_plot, sizing_mode="stretch_width")
810
+
795
811
  return layout
796
812
  else:
797
813
  # Create a panel layout without slider
@@ -1081,18 +1097,20 @@ def plot_2d_oracle(
1081
1097
  feat_df = feats.copy()
1082
1098
  feat_df = feat_df[feat_df["id_level"] == 2]
1083
1099
 
1084
- oracle_hover_1 = HoverTool(tooltips=[
1085
- ("rt", "@rt"),
1086
- ("m/z", "@mz{0.0000}"),
1087
- ("feature_uid", "@feature_uid"),
1088
- ("id_level", "@id_level"),
1089
- ("id_class", "@id_class"),
1090
- ("id_label", "@id_label"),
1091
- ("id_ion", "@id_ion"),
1092
- ("id_evidence", "@id_evidence"),
1093
- ("score", "@score"),
1094
- ("score2", "@score2"),
1095
- ])
1100
+ oracle_hover_1 = HoverTool(
1101
+ tooltips=[
1102
+ ("rt", "@rt"),
1103
+ ("m/z", "@mz{0.0000}"),
1104
+ ("feature_uid", "@feature_uid"),
1105
+ ("id_level", "@id_level"),
1106
+ ("id_class", "@id_class"),
1107
+ ("id_label", "@id_label"),
1108
+ ("id_ion", "@id_ion"),
1109
+ ("id_evidence", "@id_evidence"),
1110
+ ("score", "@score"),
1111
+ ("score2", "@score2"),
1112
+ ],
1113
+ )
1096
1114
  feature_points_1 = hv.Points(
1097
1115
  feat_df,
1098
1116
  kdims=["rt", "mz"],
@@ -1122,15 +1140,17 @@ def plot_2d_oracle(
1122
1140
  feat_df = feats.copy()
1123
1141
  feat_df = feat_df[(feat_df["ms2_scans"].notnull()) & (feat_df["id_level"] == 1)]
1124
1142
  if len(feat_df) > 0:
1125
- oracle_hover_2 = HoverTool(tooltips=[
1126
- ("rt", "@rt"),
1127
- ("m/z", "@mz{0.0000}"),
1128
- ("feature_uid", "@feature_uid"),
1129
- ("id_level", "@id_level"),
1130
- ("id_label", "@id_label"),
1131
- ("id_ion", "@id_ion"),
1132
- ("id_class", "@id_class"),
1133
- ])
1143
+ oracle_hover_2 = HoverTool(
1144
+ tooltips=[
1145
+ ("rt", "@rt"),
1146
+ ("m/z", "@mz{0.0000}"),
1147
+ ("feature_uid", "@feature_uid"),
1148
+ ("id_level", "@id_level"),
1149
+ ("id_label", "@id_label"),
1150
+ ("id_ion", "@id_ion"),
1151
+ ("id_class", "@id_class"),
1152
+ ],
1153
+ )
1134
1154
  feature_points_2 = hv.Points(
1135
1155
  feat_df,
1136
1156
  kdims=["rt", "mz"],
@@ -1157,15 +1177,17 @@ def plot_2d_oracle(
1157
1177
  feat_df = feats.copy()
1158
1178
  feat_df = feat_df[(feat_df["ms2_scans"].isnull()) & (feat_df["id_level"] == 1)]
1159
1179
  if len(feat_df) > 0:
1160
- oracle_hover_3 = HoverTool(tooltips=[
1161
- ("rt", "@rt"),
1162
- ("m/z", "@mz{0.0000}"),
1163
- ("feature_uid", "@feature_uid"),
1164
- ("id_level", "@id_level"),
1165
- ("id_label", "@id_label"),
1166
- ("id_ion", "@id_ion"),
1167
- ("id_class", "@id_class"),
1168
- ])
1180
+ oracle_hover_3 = HoverTool(
1181
+ tooltips=[
1182
+ ("rt", "@rt"),
1183
+ ("m/z", "@mz{0.0000}"),
1184
+ ("feature_uid", "@feature_uid"),
1185
+ ("id_level", "@id_level"),
1186
+ ("id_label", "@id_label"),
1187
+ ("id_ion", "@id_ion"),
1188
+ ("id_class", "@id_class"),
1189
+ ],
1190
+ )
1169
1191
  feature_points_3 = hv.Points(
1170
1192
  feat_df,
1171
1193
  kdims=["rt", "mz"],
@@ -1192,12 +1214,14 @@ def plot_2d_oracle(
1192
1214
  feat_df = feats.copy()
1193
1215
  feat_df = feat_df[(feat_df["ms2_scans"].notnull()) & (feat_df["id_level"] < 1)]
1194
1216
  if len(feat_df) > 0:
1195
- oracle_hover_4 = HoverTool(tooltips=[
1196
- ("rt", "@rt"),
1197
- ("m/z", "@mz{0.0000}"),
1198
- ("feature_uid", "@feature_uid"),
1199
- ("inty", "@inty"),
1200
- ])
1217
+ oracle_hover_4 = HoverTool(
1218
+ tooltips=[
1219
+ ("rt", "@rt"),
1220
+ ("m/z", "@mz{0.0000}"),
1221
+ ("feature_uid", "@feature_uid"),
1222
+ ("inty", "@inty"),
1223
+ ],
1224
+ )
1201
1225
  feature_points_4 = hv.Points(
1202
1226
  feat_df,
1203
1227
  kdims=["rt", "mz"],
@@ -1216,12 +1240,14 @@ def plot_2d_oracle(
1216
1240
  feat_df = feats.copy()
1217
1241
  feat_df = feat_df[(feat_df["ms2_scans"].isnull()) & (feat_df["id_level"] < 1)]
1218
1242
  if len(feat_df) > 0:
1219
- oracle_hover_5 = HoverTool(tooltips=[
1220
- ("rt", "@rt"),
1221
- ("m/z", "@mz{0.0000}"),
1222
- ("feature_uid", "@feature_uid"),
1223
- ("inty", "@inty"),
1224
- ])
1243
+ oracle_hover_5 = HoverTool(
1244
+ tooltips=[
1245
+ ("rt", "@rt"),
1246
+ ("m/z", "@mz{0.0000}"),
1247
+ ("feature_uid", "@feature_uid"),
1248
+ ("inty", "@inty"),
1249
+ ],
1250
+ )
1225
1251
  feature_points_5 = hv.Points(
1226
1252
  feat_df,
1227
1253
  kdims=["rt", "mz"],
@@ -1941,19 +1967,102 @@ def plot_tic(
1941
1967
  title=None,
1942
1968
  filename=None,
1943
1969
  ):
1944
- # get all ms_level ==1 scans from sefl.scans_df
1945
- scans = self.scans_df.filter(pl.col("ms_level") == 1)
1946
- # select rt, scan_uid and inty_tot, convert to pandas
1947
- data = scans[["rt", "scan_uid", "inty_tot"]].to_pandas()
1948
- # sort by rt
1949
- data = data.sort_values("rt")
1950
-
1951
- # plot using hv.Curve
1952
- tic = hv.Curve(data, kdims=["rt"], vdims=["inty_tot"])
1953
- tic.opts(
1954
- title=title,
1955
- xlabel="Retention Time (min)",
1956
- ylabel="TIC",
1957
- height=250,
1958
- width=100,
1959
- )
1970
+ """
1971
+ Plot Total Ion Chromatogram (TIC) by summing MS1 peak intensities at each retention time.
1972
+
1973
+ Uses `self.ms1_df` (Polars DataFrame) and aggregates intensities by `rt` (sum).
1974
+ Creates a `Chromatogram` object and uses its `plot()` method to display the result.
1975
+ """
1976
+ if self.ms1_df is None:
1977
+ self.logger.error("No MS1 data available.")
1978
+ return
1979
+
1980
+ # Import helper locally to avoid circular imports
1981
+ from masster.study.helpers import get_tic
1982
+
1983
+ # Delegate TIC computation to study helper which handles ms1_df and scans_df fallbacks
1984
+ try:
1985
+ chrom = get_tic(self, label=title)
1986
+ except Exception as e:
1987
+ self.logger.exception("Failed to compute TIC via helper: %s", e)
1988
+ return
1989
+
1990
+ if filename is not None:
1991
+ try:
1992
+ chrom.plot(width=1000, height=250)
1993
+ except Exception:
1994
+ import matplotlib.pyplot as plt
1995
+
1996
+ plt.figure(figsize=(10, 3))
1997
+ plt.plot(chrom.rt, chrom.inty, color="black")
1998
+ plt.xlabel("Retention time (s)")
1999
+ plt.ylabel("Intensity")
2000
+ if title:
2001
+ plt.title(title)
2002
+ plt.tight_layout()
2003
+ plt.savefig(filename)
2004
+ return None
2005
+
2006
+ chrom.plot(width=1000, height=250)
2007
+ return None
2008
+
2009
+
2010
+ def plot_bpc(
2011
+ self,
2012
+ title=None,
2013
+ filename=None,
2014
+ rt_unit="s",
2015
+ ):
2016
+ """
2017
+ Plot Base Peak Chromatogram (BPC) using MS1 data.
2018
+
2019
+ Aggregates MS1 points by retention time and selects the maximum intensity (base peak)
2020
+ at each time point. Uses `self.ms1_df` (Polars DataFrame) as the source of MS1 peaks.
2021
+
2022
+ Parameters:
2023
+ title (str, optional): Plot title.
2024
+ filename (str, optional): If provided and ends with `.html` saves an interactive html,
2025
+ otherwise saves a png. If None, returns a displayable object for notebooks.
2026
+ rt_unit (str, optional): Unit label for the x-axis, default 's' (seconds).
2027
+
2028
+ Returns:
2029
+ None or notebook display object (via _display_plot)
2030
+ """
2031
+ if self.ms1_df is None:
2032
+ self.logger.error("No MS1 data available.")
2033
+ return
2034
+
2035
+ # Import helper locally to avoid circular imports
2036
+ from masster.study.helpers import get_bpc
2037
+
2038
+ # Delegate BPC computation to study helper
2039
+ try:
2040
+ chrom = get_bpc(self, rt_unit=rt_unit, label=title)
2041
+ except Exception as e:
2042
+ self.logger.exception("Failed to compute BPC via helper: %s", e)
2043
+ return
2044
+
2045
+ # If filename was requested, save a static png using bokeh export via the chromatogram plotting
2046
+ if filename is not None:
2047
+ # chromatogram.plot() uses bokeh to show the figure; to save as png we rely on holoviews/hv.save
2048
+ # Create a bokeh figure by plotting to an offscreen axis
2049
+ try:
2050
+ # Use Chromatogram.plot to generate and show the figure (will open in notebook/browser)
2051
+ chrom.plot(width=1000, height=250)
2052
+ except Exception:
2053
+ # Last-resort: create a simple matplotlib plot and save
2054
+ import matplotlib.pyplot as plt
2055
+
2056
+ plt.figure(figsize=(10, 3))
2057
+ plt.plot(chrom.rt, chrom.inty, color="black")
2058
+ plt.xlabel(f"Retention time ({rt_unit})")
2059
+ plt.ylabel("Intensity")
2060
+ if title:
2061
+ plt.title(title)
2062
+ plt.tight_layout()
2063
+ plt.savefig(filename)
2064
+ return None
2065
+
2066
+ # No filename: display using the chromatogram's built-in plotting (bokeh)
2067
+ chrom.plot(width=1000, height=250)
2068
+ return None