masster 0.2.5__py3-none-any.whl → 0.3.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.

Potentially problematic release.


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

Files changed (55) hide show
  1. masster/__init__.py +27 -27
  2. masster/_version.py +17 -17
  3. masster/chromatogram.py +497 -503
  4. masster/data/examples/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.featureXML +199787 -0
  5. masster/data/examples/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.sample5 +0 -0
  6. masster/logger.py +318 -244
  7. masster/sample/__init__.py +9 -9
  8. masster/sample/defaults/__init__.py +15 -15
  9. masster/sample/defaults/find_adducts_def.py +325 -325
  10. masster/sample/defaults/find_features_def.py +366 -366
  11. masster/sample/defaults/find_ms2_def.py +285 -285
  12. masster/sample/defaults/get_spectrum_def.py +314 -318
  13. masster/sample/defaults/sample_def.py +374 -378
  14. masster/sample/h5.py +1321 -1297
  15. masster/sample/helpers.py +833 -364
  16. masster/sample/lib.py +762 -0
  17. masster/sample/load.py +1220 -1187
  18. masster/sample/parameters.py +131 -131
  19. masster/sample/plot.py +1685 -1622
  20. masster/sample/processing.py +1402 -1416
  21. masster/sample/quant.py +209 -0
  22. masster/sample/sample.py +393 -387
  23. masster/sample/sample5_schema.json +181 -181
  24. masster/sample/save.py +737 -736
  25. masster/sample/sciex.py +1213 -0
  26. masster/spectrum.py +1287 -1319
  27. masster/study/__init__.py +9 -9
  28. masster/study/defaults/__init__.py +21 -19
  29. masster/study/defaults/align_def.py +267 -267
  30. masster/study/defaults/export_def.py +41 -40
  31. masster/study/defaults/fill_chrom_def.py +264 -264
  32. masster/study/defaults/fill_def.py +260 -0
  33. masster/study/defaults/find_consensus_def.py +256 -256
  34. masster/study/defaults/find_ms2_def.py +163 -163
  35. masster/study/defaults/integrate_chrom_def.py +225 -225
  36. masster/study/defaults/integrate_def.py +221 -0
  37. masster/study/defaults/merge_def.py +256 -0
  38. masster/study/defaults/study_def.py +272 -269
  39. masster/study/export.py +674 -287
  40. masster/study/h5.py +1406 -886
  41. masster/study/helpers.py +1713 -433
  42. masster/study/helpers_optimized.py +317 -0
  43. masster/study/load.py +1231 -1078
  44. masster/study/parameters.py +99 -99
  45. masster/study/plot.py +632 -645
  46. masster/study/processing.py +1057 -1046
  47. masster/study/save.py +161 -134
  48. masster/study/study.py +612 -522
  49. masster/study/study5_schema.json +253 -241
  50. {masster-0.2.5.dist-info → masster-0.3.1.dist-info}/METADATA +15 -10
  51. masster-0.3.1.dist-info/RECORD +59 -0
  52. {masster-0.2.5.dist-info → masster-0.3.1.dist-info}/licenses/LICENSE +661 -661
  53. masster-0.2.5.dist-info/RECORD +0 -50
  54. {masster-0.2.5.dist-info → masster-0.3.1.dist-info}/WHEEL +0 -0
  55. {masster-0.2.5.dist-info → masster-0.3.1.dist-info}/entry_points.txt +0 -0
masster/study/plot.py CHANGED
@@ -1,645 +1,632 @@
1
- from __future__ import annotations
2
-
3
- from datetime import datetime
4
- from typing import Any
5
-
6
- import holoviews as hv
7
- import numpy as np
8
- import panel
9
- import polars as pl
10
-
11
- from bokeh.io.export import export_png
12
- from bokeh.models import ColumnDataSource
13
- from bokeh.models import HoverTool
14
- from bokeh.palettes import Turbo256
15
- from bokeh.plotting import figure
16
- from bokeh.plotting import output_file
17
- from bokeh.plotting import show
18
- from tqdm import tqdm
19
-
20
- hv.extension("bokeh")
21
-
22
-
23
- def plot_alignment(self, filename=None):
24
- import matplotlib.pyplot as plt
25
- import numpy as np
26
-
27
- if self.features_maps is None or len(self.features_maps) == 0:
28
- self.load_features()
29
-
30
- feature_maps = self.features_maps
31
- ref_index = self.alignment_ref_index
32
- if ref_index is None:
33
- self.logger.error("No alignment performed yet.")
34
- return
35
-
36
- fmaps = [
37
- feature_maps[ref_index],
38
- *feature_maps[:ref_index],
39
- *feature_maps[ref_index + 1 :],
40
- ]
41
-
42
- fig = plt.figure(figsize=(12, 6))
43
-
44
- ax = fig.add_subplot(1, 2, 1)
45
- ax.set_title("Feature maps before alignment")
46
- ax.set_ylabel("m/z")
47
- ax.set_xlabel("RT")
48
-
49
- # use alpha value to display feature intensity
50
- ax.scatter(
51
- [f.getRT() for f in fmaps[0]],
52
- [f.getMZ() for f in fmaps[0]],
53
- alpha=np.asarray([f.getIntensity() for f in fmaps[0]])
54
- / max([f.getIntensity() for f in fmaps[0]]),
55
- s=4,
56
- )
57
-
58
- for fm in fmaps[1:]:
59
- ax.scatter(
60
- [f.getMetaValue("original_RT") for f in fm],
61
- [f.getMZ() for f in fm],
62
- alpha=np.asarray([f.getIntensity() for f in fm])
63
- / max([f.getIntensity() for f in fm]),
64
- s=2, # Set symbol size to 3
65
- )
66
-
67
- ax = fig.add_subplot(1, 2, 2)
68
- ax.set_title("Feature maps after alignment")
69
- ax.set_ylabel("m/z")
70
- ax.set_xlabel("RT")
71
-
72
- for fm in fmaps:
73
- ax.scatter(
74
- [f.getRT() for f in fm],
75
- [f.getMZ() for f in fm],
76
- alpha=np.asarray([f.getIntensity() for f in fm])
77
- / max([f.getIntensity() for f in fm]),
78
- s=2, # Set symbol size to 3
79
- )
80
-
81
- fig.tight_layout()
82
-
83
-
84
- def plot_alignment_bokeh(self, filename=None):
85
- from bokeh.plotting import figure, show, output_file
86
- from bokeh.layouts import gridplot
87
-
88
- feature_maps = self.features_maps
89
- ref_index = self.alignment_ref_index
90
- if ref_index is None:
91
- self.logger.warning("No alignment performed yet.")
92
- return
93
-
94
- fmaps = [
95
- feature_maps[ref_index],
96
- *feature_maps[:ref_index],
97
- *feature_maps[ref_index + 1 :],
98
- ]
99
-
100
- # Create Bokeh figures
101
- p1 = figure(
102
- title="Feature maps before alignment",
103
- width=600,
104
- height=400,
105
- )
106
- p1.xaxis.axis_label = "RT"
107
- p1.yaxis.axis_label = "m/z"
108
- p2 = figure(
109
- title="Feature maps after alignment",
110
- width=600,
111
- height=400,
112
- )
113
- p2.xaxis.axis_label = "RT"
114
- p2.yaxis.axis_label = "m/z"
115
-
116
- # Plot before alignment
117
- p1.scatter(
118
- x=[f.getRT() for f in fmaps[0]],
119
- y=[f.getMZ() for f in fmaps[0]],
120
- size=4,
121
- alpha=[
122
- f.getIntensity() / max([f.getIntensity() for f in fmaps[0]])
123
- for f in fmaps[0]
124
- ],
125
- color="blue",
126
- )
127
-
128
- for fm in fmaps[1:]:
129
- p1.scatter(
130
- x=[f.getMetaValue("original_RT") for f in fm],
131
- y=[f.getMZ() for f in fm],
132
- size=2,
133
- alpha=[f.getIntensity() / max([f.getIntensity() for f in fm]) for f in fm],
134
- color="green",
135
- )
136
-
137
- # Plot after alignment
138
- for fm in fmaps:
139
- p2.scatter(
140
- x=[f.getRT() for f in fm],
141
- y=[f.getMZ() for f in fm],
142
- size=2,
143
- alpha=[f.getIntensity() / max([f.getIntensity() for f in fm]) for f in fm],
144
- color="red",
145
- )
146
-
147
- # Arrange plots in a grid
148
- # Link the x_range and y_range of both plots for synchronized zooming/panning
149
- p2.x_range = p1.x_range
150
- p2.y_range = p1.y_range
151
-
152
- grid = gridplot([[p1, p2]])
153
-
154
- # Output to file and show
155
- if filename:
156
- output_file(filename)
157
- show(grid)
158
-
159
-
160
- def plot_consensus_2d(
161
- self,
162
- filename=None,
163
- colorby="number_samples",
164
- sizeby="inty_mean",
165
- markersize=6,
166
- alpha=0.7,
167
- cmap=None,
168
- ):
169
- if self.consensus_df is None:
170
- self.logger.error("No consensus map found.")
171
- return
172
- data = self.consensus_df.clone()
173
- if colorby not in data.columns:
174
- self.logger.error(f"Column {colorby} not found in consensus_df.")
175
- return
176
- if sizeby not in data.columns:
177
- self.logger.warning(f"Column {sizeby} not found in consensus_df.")
178
- sizeby = None
179
- # if sizeby is not None, set markersize to sizeby
180
- if sizeby is not None:
181
- # set markersize to sizeby
182
- if sizeby in ["inty_mean"]:
183
- # use log10 of sizeby
184
- # Filter out empty or all-NA entries before applying np.log10
185
- data = data.with_columns([
186
- pl.when(
187
- (pl.col(sizeby).is_not_null())
188
- & (pl.col(sizeby).is_finite())
189
- & (pl.col(sizeby) > 0),
190
- )
191
- .then((pl.col(sizeby).log10() * markersize / 12).pow(2))
192
- .otherwise(markersize)
193
- .alias("markersize"),
194
- ])
195
- else:
196
- max_size = data[sizeby].max()
197
- data = data.with_columns([
198
- (pl.col(sizeby) / max_size * markersize).alias("markersize"),
199
- ])
200
- else:
201
- data = data.with_columns([pl.lit(markersize).alias("markersize")])
202
- # sort by ascending colorby
203
- data = data.sort(colorby)
204
- # convert consensus_id to string - check if column exists
205
- if "consensus_id" in data.columns:
206
- # Handle Object dtype by converting to string first
207
- data = data.with_columns([
208
- pl.col("consensus_id")
209
- .map_elements(
210
- lambda x: str(x) if x is not None else None,
211
- return_dtype=pl.Utf8,
212
- )
213
- .alias("consensus_id"),
214
- ])
215
- elif "consensus_uid" in data.columns:
216
- data = data.with_columns([
217
- pl.col("consensus_uid").cast(pl.Utf8).alias("consensus_id"),
218
- ])
219
-
220
- if cmap is None:
221
- cmap = "vi"
222
- elif cmap == "grey":
223
- cmap = "Greys256"
224
-
225
- # plot with bokeh
226
- import bokeh.plotting as bp
227
-
228
- from bokeh.models import BasicTicker
229
- from bokeh.models import ColumnDataSource
230
- from bokeh.models import HoverTool
231
- from bokeh.models import LinearColorMapper
232
- try:
233
- from bokeh.models import ColorBar # type: ignore[attr-defined]
234
- except ImportError:
235
- from bokeh.models.annotations import ColorBar
236
- from bokeh.palettes import viridis
237
-
238
- # Convert Polars DataFrame to pandas for Bokeh compatibility
239
- data_pd = data.to_pandas()
240
- source = ColumnDataSource(data_pd)
241
- color_mapper = LinearColorMapper(
242
- palette=viridis(256),
243
- low=data[colorby].min(),
244
- high=data[colorby].max(),
245
- )
246
- # scatter plot rt vs mz
247
- p = bp.figure(
248
- width=800,
249
- height=600,
250
- title="Consensus map",
251
- )
252
- p.xaxis.axis_label = "Retention Time (min)"
253
- p.yaxis.axis_label = "m/z"
254
- scatter_renderer = p.scatter(
255
- x="rt",
256
- y="mz",
257
- size="markersize",
258
- fill_color={"field": colorby, "transform": color_mapper},
259
- line_color=None,
260
- alpha=alpha,
261
- source=source,
262
- )
263
- # add hover tool
264
- hover = HoverTool(
265
- tooltips=[
266
- ("consensus_uid", "@consensus_uid"),
267
- ("consensus_id", "@consensus_id"),
268
- ("number_samples", "@number_samples"),
269
- ("number_ms2", "@number_ms2"),
270
- ("rt", "@rt"),
271
- ("mz", "@mz"),
272
- ("inty_mean", "@inty_mean"),
273
- ("iso_mean", "@iso_mean"),
274
- ("coherence_mean", "@chrom_coherence_mean"),
275
- ("prominence_mean", "@chrom_prominence_mean"),
276
- ],
277
- renderers=[scatter_renderer],
278
- )
279
- p.add_tools(hover)
280
-
281
- # add colorbar
282
- color_bar = ColorBar(
283
- color_mapper=color_mapper,
284
- label_standoff=12,
285
- location=(0, 0),
286
- title=colorby,
287
- ticker=BasicTicker(desired_num_ticks=8),
288
- )
289
- p.add_layout(color_bar, "right")
290
-
291
- if filename is not None:
292
- bp.output_file(filename)
293
- bp.show(p)
294
- return p
295
-
296
-
297
- def plot_samples_2d(
298
- self,
299
- samples=None,
300
- filename=None,
301
- markersize=2,
302
- size="const",
303
- alpha_max=0.8,
304
- alpha="inty",
305
- cmap="Turbo256",
306
- max_features=50000, # Reduced default for better performance with many samples
307
- ):
308
- """
309
- Plot all feature maps for sample_uid in parameter uids in an overlaid scatter plot.
310
- Each sample is a different color. Alpha scales with intensity.
311
- OPTIMIZED VERSION: Uses vectorized operations and batch processing.
312
- """
313
-
314
- sample_uids = self._get_sample_uids(samples)
315
-
316
- if not sample_uids:
317
- self.logger.error("No valid sample_uids provided.")
318
- return
319
-
320
- colors = Turbo256
321
- color_map = {
322
- uid: colors[i * (256 // max(1, len(sample_uids)))]
323
- for i, uid in enumerate(sample_uids)
324
- }
325
-
326
- p = figure(
327
- width=600,
328
- height=600,
329
- title="Sample Features",
330
- )
331
- p.xaxis.axis_label = "Retention Time (RT)"
332
- p.yaxis.axis_label = "m/z"
333
-
334
- # OPTIMIZATION 1: Batch filter all features for selected samples at once
335
- features_batch = self.features_df.filter(pl.col("sample_uid").is_in(sample_uids))
336
-
337
- if features_batch.is_empty():
338
- self.logger.error("No features found for the selected samples.")
339
- return
340
-
341
- # OPTIMIZATION 8: Fast sampling for very large datasets to maintain interactivity
342
- max_features_per_plot = max_features # Limit for interactive performance
343
- total_features = len(features_batch)
344
-
345
- if total_features > max_features_per_plot:
346
- # OPTIMIZED: Much faster random sampling without groupby operations
347
- sample_ratio = max_features_per_plot / total_features
348
- self.logger.info(
349
- f"Large dataset detected ({total_features:,} features). "
350
- f"Sampling {sample_ratio:.1%} for visualization performance.",
351
- )
352
-
353
- # FAST: Use simple random sampling instead of expensive stratified sampling
354
- n_samples = min(max_features_per_plot, total_features)
355
- features_batch = features_batch.sample(n=n_samples, seed=42)
356
-
357
- # OPTIMIZATION 2: Join with samples_df to get sample names in one operation
358
- samples_info = self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
359
- features_with_names = features_batch.join(
360
- samples_info.select(["sample_uid", "sample_name"]),
361
- on="sample_uid",
362
- how="left",
363
- )
364
-
365
- # OPTIMIZATION 4: Fast pre-calculation of alpha values for all features
366
- if alpha == "inty":
367
- # OPTIMIZED: Use efficient Polars operations instead of pandas groupby transform
368
- # Calculate max intensity per sample in Polars (much faster)
369
- max_inty_per_sample = features_with_names.group_by("sample_uid").agg(
370
- pl.col("inty").max().alias("max_inty"),
371
- )
372
-
373
- # Join back and calculate alpha efficiently
374
- features_batch = (
375
- features_with_names.join(
376
- max_inty_per_sample,
377
- on="sample_uid",
378
- how="left",
379
- )
380
- .with_columns(
381
- (pl.col("inty") / pl.col("max_inty") * alpha_max).alias("alpha"),
382
- )
383
- .drop("max_inty")
384
- )
385
-
386
- # Convert to pandas once after all Polars operations
387
- features_pd = features_batch.to_pandas()
388
- else:
389
- # Convert to pandas and add constant alpha
390
- features_pd = features_with_names.to_pandas()
391
- features_pd["alpha"] = alpha_max
392
-
393
- # OPTIMIZATION 9: NEW - Batch create all ColumnDataSources at once
394
- # Group all data by sample_uid and create sources efficiently
395
- sources = {}
396
- renderers: list[Any] = []
397
-
398
- # Pre-compute color mapping to avoid repeated lookups
399
- color_values = {}
400
- sample_names = {}
401
-
402
- for uid in sample_uids:
403
- sample_data = features_pd[features_pd["sample_uid"] == uid]
404
- if sample_data.empty:
405
- continue
406
-
407
- sample_name = sample_data["sample_name"].iloc[0]
408
- sample_names[uid] = sample_name
409
- color_values[uid] = color_map[uid]
410
-
411
- # OPTIMIZATION 10: Batch renderer creation with pre-computed values
412
- for uid in sample_uids:
413
- sample_data = features_pd[features_pd["sample_uid"] == uid]
414
- if sample_data.empty:
415
- continue
416
-
417
- sample_name = sample_names[uid]
418
- color_values[uid]
419
-
420
- # OPTIMIZATION 11: Direct numpy array access for better performance
421
- source = ColumnDataSource(
422
- data={
423
- "rt": sample_data["rt"].values,
424
- "mz": sample_data["mz"].values,
425
- "inty": sample_data["inty"].values,
426
- "alpha": sample_data["alpha"].values,
427
- "sample": np.full(len(sample_data), sample_name, dtype=object),
428
- },
429
- )
430
-
431
- sources[uid] = source
432
-
433
- # OPTIMIZATION 12: Use pre-computed color value
434
- # Create renderer with pre-computed values
435
- renderer: Any
436
- if size.lower() in ["dyn", "dynamic"]:
437
- renderer = p.circle(
438
- x="rt",
439
- y="mz",
440
- radius=markersize / 10,
441
- color=color_values[uid],
442
- alpha="alpha",
443
- legend_label=sample_name,
444
- source=source,
445
- )
446
- else:
447
- renderer = p.scatter(
448
- x="rt",
449
- y="mz",
450
- size=markersize,
451
- color=color_values[uid],
452
- alpha="alpha",
453
- legend_label=sample_name,
454
- source=source,
455
- )
456
- renderers.append(renderer)
457
-
458
- # OPTIMIZATION 13: Simplified hover tool for better performance with many samples
459
- if renderers:
460
- hover = HoverTool(
461
- tooltips=[
462
- ("sample", "@sample"),
463
- ("rt", "@rt{0.00}"),
464
- ("mz", "@mz{0.0000}"),
465
- ("intensity", "@inty{0.0e+0}"),
466
- ],
467
- renderers=renderers,
468
- )
469
- p.add_tools(hover)
470
-
471
- # Remove legend from plot
472
- p.legend.visible = False
473
- if filename:
474
- if filename.endswith(".html"):
475
- output_file(filename)
476
- show(p)
477
- elif filename.endswith(".png"):
478
- export_png(p, filename=filename)
479
- else:
480
- output_file(filename)
481
- show(p)
482
- else:
483
- show(p)
484
- return
485
-
486
-
487
- def plot_chrom(
488
- self,
489
- uids=None,
490
- samples=None,
491
- filename=None,
492
- aligned=True,
493
- width=800,
494
- height=300,
495
- ):
496
- cons_uids = self._get_consensus_uids(uids)
497
- sample_uids = self._get_sample_uids(samples)
498
-
499
- chroms = self.get_chrom(uids=cons_uids, samples=sample_uids)
500
-
501
- if chroms is None or chroms.is_empty():
502
- self.logger.error("No chromatogram data found.")
503
- return
504
-
505
- # Assign a fixed color to each sample/column
506
- sample_names = [col for col in chroms.columns if col not in ["consensus_uid"]]
507
- if not sample_names:
508
- self.logger.error("No sample names found in chromatogram data.")
509
- return
510
- color_map = {
511
- sample: Turbo256[i * (256 // max(1, len(sample_names)))]
512
- for i, sample in enumerate(sample_names)
513
- }
514
-
515
- plots = []
516
- self.logger.info(f"Plotting {chroms.shape[0]} chromatograms...")
517
- tdqm_disable = self.log_level not in ["TRACE", "DEBUG", "INFO"]
518
- for row in tqdm(
519
- chroms.iter_rows(named=True),
520
- total=chroms.shape[0],
521
- desc=f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} | INFO | {self.log_label}Plot chromatograms",
522
- disable=tdqm_disable,
523
- ):
524
- consensus_uid = row["consensus_uid"] # Get consensus_uid from the row
525
- consensus_id = consensus_uid # Use the same value for consensus_id
526
- curves = []
527
- rt_min = np.inf
528
- rt_max = 0
529
- for sample in sample_names:
530
- chrom = row[sample]
531
- if chrom is not None:
532
- # check if chrom is nan
533
- if isinstance(chrom, float) and np.isnan(chrom):
534
- continue
535
-
536
- chrom = chrom.to_dict()
537
- rt = chrom["rt"].copy()
538
- if len(rt) == 0:
539
- continue
540
- if aligned and "rt_shift" in chrom:
541
- rt_shift = chrom["rt_shift"]
542
- if rt_shift is not None:
543
- # Convert to numpy array if it's a list, then add scalar
544
- if isinstance(rt, list):
545
- rt = np.array(rt)
546
- rt = rt + rt_shift # Add scalar to array
547
-
548
- # update rt_min and rt_max
549
- if rt[0] < rt_min:
550
- rt_min = rt[0]
551
- if rt[-1] > rt_max:
552
- rt_max = rt[-1]
553
-
554
- inty = chrom["inty"]
555
-
556
- # Convert both rt and inty to numpy arrays if they're lists
557
- if isinstance(rt, list):
558
- rt = np.array(rt)
559
- if isinstance(inty, list):
560
- inty = np.array(inty)
561
-
562
- # Ensure both rt and inty are arrays and have the same length and are not empty
563
- if rt.size > 0 and inty.size > 0 and rt.shape == inty.shape:
564
- # sort rt and inty by rt
565
- sorted_indices = np.argsort(rt)
566
- rt = rt[sorted_indices]
567
- inty = inty[sorted_indices]
568
- curve = hv.Curve((rt, inty), kdims=["RT"], vdims=["inty"]).opts(
569
- color=color_map[sample],
570
- line_width=1,
571
- )
572
- curves.append(curve)
573
-
574
- if "feature_start" in chrom and "feature_end" in chrom:
575
- # Add vertical lines for feature start and end
576
- feature_start = chrom["feature_start"]
577
- feature_end = chrom["feature_end"]
578
- if aligned and "rt_shift" in chrom:
579
- rt_shift = chrom["rt_shift"]
580
- if rt_shift is not None:
581
- feature_start += rt_shift
582
- feature_end += rt_shift
583
- if feature_start < rt_min:
584
- rt_min = feature_start
585
- if feature_end > rt_max:
586
- rt_max = feature_end
587
- # Add vertical lines to the curves
588
- curves.append(
589
- hv.VLine(feature_start).opts(
590
- color=color_map[sample],
591
- line_dash="dotted",
592
- line_width=1,
593
- ),
594
- )
595
- curves.append(
596
- hv.VLine(feature_end).opts(
597
- color=color_map[sample],
598
- line_dash="dotted",
599
- line_width=1,
600
- ),
601
- )
602
- if curves:
603
- # find row in consensus_df with consensus_id
604
- consensus_row = self.consensus_df.filter(
605
- pl.col("consensus_uid") == consensus_id,
606
- )
607
- rt_start_mean = consensus_row["rt_start_mean"][0]
608
- rt_end_mean = consensus_row["rt_end_mean"][0]
609
- # Add vertical lines to overlay
610
- curves.append(hv.VLine(rt_start_mean).opts(color="black", line_width=2))
611
- curves.append(hv.VLine(rt_end_mean).opts(color="black", line_width=2))
612
-
613
- overlay = hv.Overlay(curves).opts(
614
- height=height,
615
- width=width,
616
- title=f"Consensus UID: {consensus_id}, mz: {consensus_row['mz'][0]:.4f}, rt: {consensus_row['rt'][0]:.2f}{' (aligned)' if aligned else ''}",
617
- xlim=(rt_min, rt_max),
618
- shared_axes=False,
619
- )
620
- plots.append(overlay)
621
-
622
- if not plots:
623
- self.logger.warning("No valid chromatogram curves to plot.")
624
- return
625
-
626
- # stack vertically.
627
- # Stack all plots vertically in a Panel column
628
- layout = panel.Column(*[panel.panel(plot) for plot in plots])
629
- if filename is not None:
630
- if filename.endswith(".html"):
631
- panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
632
- else:
633
- # Save as PNG using Panel's export_png if filename ends with .png
634
- if filename.endswith(".png"):
635
- from panel.io.save import save_png
636
-
637
- # Convert Holoviews overlays to Bokeh models before saving
638
- bokeh_layout = panel.panel(layout).get_root() # type: ignore[attr-defined]
639
- save_png(bokeh_layout, filename=filename)
640
- else:
641
- panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
642
- else:
643
- # In a server context, return the panel object instead of showing or saving directly
644
- # return panel.panel(layout)
645
- panel.panel(layout).show()
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ import holoviews as hv
7
+ import numpy as np
8
+ import panel
9
+ import polars as pl
10
+
11
+ from bokeh.io.export import export_png
12
+ from bokeh.models import ColumnDataSource
13
+ from bokeh.models import HoverTool
14
+ from bokeh.palettes import Turbo256
15
+ from bokeh.plotting import figure
16
+ from bokeh.plotting import output_file
17
+ from bokeh.plotting import show
18
+ from tqdm import tqdm
19
+
20
+ hv.extension("bokeh")
21
+
22
+
23
+ def plot_alignment(self, filename=None):
24
+ import matplotlib.pyplot as plt
25
+ import numpy as np
26
+
27
+ if self.features_maps is None or len(self.features_maps) == 0:
28
+ self.load_features()
29
+
30
+ feature_maps = self.features_maps
31
+ ref_index = self.alignment_ref_index
32
+ if ref_index is None:
33
+ self.logger.error("No alignment performed yet.")
34
+ return
35
+
36
+ fmaps = [
37
+ feature_maps[ref_index],
38
+ *feature_maps[:ref_index],
39
+ *feature_maps[ref_index + 1 :],
40
+ ]
41
+
42
+ fig = plt.figure(figsize=(12, 6))
43
+
44
+ ax = fig.add_subplot(1, 2, 1)
45
+ ax.set_title("Feature maps before alignment")
46
+ ax.set_ylabel("m/z")
47
+ ax.set_xlabel("RT")
48
+
49
+ # use alpha value to display feature intensity
50
+ ax.scatter(
51
+ [f.getRT() for f in fmaps[0]],
52
+ [f.getMZ() for f in fmaps[0]],
53
+ alpha=np.asarray([f.getIntensity() for f in fmaps[0]]) / max([f.getIntensity() for f in fmaps[0]]),
54
+ s=4,
55
+ )
56
+
57
+ for fm in fmaps[1:]:
58
+ ax.scatter(
59
+ [f.getMetaValue("original_RT") for f in fm],
60
+ [f.getMZ() for f in fm],
61
+ alpha=np.asarray([f.getIntensity() for f in fm]) / max([f.getIntensity() for f in fm]),
62
+ s=2, # Set symbol size to 3
63
+ )
64
+
65
+ ax = fig.add_subplot(1, 2, 2)
66
+ ax.set_title("Feature maps after alignment")
67
+ ax.set_ylabel("m/z")
68
+ ax.set_xlabel("RT")
69
+
70
+ for fm in fmaps:
71
+ ax.scatter(
72
+ [f.getRT() for f in fm],
73
+ [f.getMZ() for f in fm],
74
+ alpha=np.asarray([f.getIntensity() for f in fm]) / max([f.getIntensity() for f in fm]),
75
+ s=2, # Set symbol size to 3
76
+ )
77
+
78
+ fig.tight_layout()
79
+
80
+
81
+ def plot_alignment_bokeh(self, filename=None):
82
+ from bokeh.plotting import figure, show, output_file
83
+ from bokeh.layouts import gridplot
84
+
85
+ feature_maps = self.features_maps
86
+ ref_index = self.alignment_ref_index
87
+ if ref_index is None:
88
+ self.logger.warning("No alignment performed yet.")
89
+ return
90
+
91
+ fmaps = [
92
+ feature_maps[ref_index],
93
+ *feature_maps[:ref_index],
94
+ *feature_maps[ref_index + 1 :],
95
+ ]
96
+
97
+ # Create Bokeh figures
98
+ p1 = figure(
99
+ title="Feature maps before alignment",
100
+ width=600,
101
+ height=400,
102
+ )
103
+ p1.xaxis.axis_label = "RT"
104
+ p1.yaxis.axis_label = "m/z"
105
+ p2 = figure(
106
+ title="Feature maps after alignment",
107
+ width=600,
108
+ height=400,
109
+ )
110
+ p2.xaxis.axis_label = "RT"
111
+ p2.yaxis.axis_label = "m/z"
112
+
113
+ # Plot before alignment
114
+ p1.scatter(
115
+ x=[f.getRT() for f in fmaps[0]],
116
+ y=[f.getMZ() for f in fmaps[0]],
117
+ size=4,
118
+ alpha=[f.getIntensity() / max([f.getIntensity() for f in fmaps[0]]) for f in fmaps[0]],
119
+ color="blue",
120
+ )
121
+
122
+ for fm in fmaps[1:]:
123
+ p1.scatter(
124
+ x=[f.getMetaValue("original_RT") for f in fm],
125
+ y=[f.getMZ() for f in fm],
126
+ size=2,
127
+ alpha=[f.getIntensity() / max([f.getIntensity() for f in fm]) for f in fm],
128
+ color="green",
129
+ )
130
+
131
+ # Plot after alignment
132
+ for fm in fmaps:
133
+ p2.scatter(
134
+ x=[f.getRT() for f in fm],
135
+ y=[f.getMZ() for f in fm],
136
+ size=2,
137
+ alpha=[f.getIntensity() / max([f.getIntensity() for f in fm]) for f in fm],
138
+ color="red",
139
+ )
140
+
141
+ # Arrange plots in a grid
142
+ # Link the x_range and y_range of both plots for synchronized zooming/panning
143
+ p2.x_range = p1.x_range
144
+ p2.y_range = p1.y_range
145
+
146
+ grid = gridplot([[p1, p2]])
147
+
148
+ # Output to file and show
149
+ if filename:
150
+ output_file(filename)
151
+ show(grid)
152
+
153
+
154
+ def plot_consensus_2d(
155
+ self,
156
+ filename=None,
157
+ colorby="number_samples",
158
+ sizeby="inty_mean",
159
+ markersize=6,
160
+ alpha=0.7,
161
+ cmap=None,
162
+ ):
163
+ if self.consensus_df is None:
164
+ self.logger.error("No consensus map found.")
165
+ return
166
+ data = self.consensus_df.clone()
167
+ if colorby not in data.columns:
168
+ self.logger.error(f"Column {colorby} not found in consensus_df.")
169
+ return
170
+ if sizeby not in data.columns:
171
+ self.logger.warning(f"Column {sizeby} not found in consensus_df.")
172
+ sizeby = None
173
+ # if sizeby is not None, set markersize to sizeby
174
+ if sizeby is not None:
175
+ # set markersize to sizeby
176
+ if sizeby in ["inty_mean"]:
177
+ # use log10 of sizeby
178
+ # Filter out empty or all-NA entries before applying np.log10
179
+ data = data.with_columns([
180
+ pl.when(
181
+ (pl.col(sizeby).is_not_null()) & (pl.col(sizeby).is_finite()) & (pl.col(sizeby) > 0),
182
+ )
183
+ .then((pl.col(sizeby).log10() * markersize / 12).pow(2))
184
+ .otherwise(markersize)
185
+ .alias("markersize"),
186
+ ])
187
+ else:
188
+ max_size = data[sizeby].max()
189
+ data = data.with_columns([
190
+ (pl.col(sizeby) / max_size * markersize).alias("markersize"),
191
+ ])
192
+ else:
193
+ data = data.with_columns([pl.lit(markersize).alias("markersize")])
194
+ # sort by ascending colorby
195
+ data = data.sort(colorby)
196
+ # convert consensus_id to string - check if column exists
197
+ if "consensus_id" in data.columns:
198
+ # Handle Object dtype by converting to string first
199
+ data = data.with_columns([
200
+ pl.col("consensus_id")
201
+ .map_elements(
202
+ lambda x: str(x) if x is not None else None,
203
+ return_dtype=pl.Utf8,
204
+ )
205
+ .alias("consensus_id"),
206
+ ])
207
+ elif "consensus_uid" in data.columns:
208
+ data = data.with_columns([
209
+ pl.col("consensus_uid").cast(pl.Utf8).alias("consensus_id"),
210
+ ])
211
+
212
+ if cmap is None:
213
+ cmap = "vi"
214
+ elif cmap == "grey":
215
+ cmap = "Greys256"
216
+
217
+ # plot with bokeh
218
+ import bokeh.plotting as bp
219
+
220
+ from bokeh.models import BasicTicker
221
+ from bokeh.models import ColumnDataSource
222
+ from bokeh.models import HoverTool
223
+ from bokeh.models import LinearColorMapper
224
+
225
+ try:
226
+ from bokeh.models import ColorBar # type: ignore[attr-defined]
227
+ except ImportError:
228
+ from bokeh.models.annotations import ColorBar
229
+ from bokeh.palettes import viridis
230
+
231
+ # Convert Polars DataFrame to pandas for Bokeh compatibility
232
+ data_pd = data.to_pandas()
233
+ source = ColumnDataSource(data_pd)
234
+ color_mapper = LinearColorMapper(
235
+ palette=viridis(256),
236
+ low=data[colorby].min(),
237
+ high=data[colorby].max(),
238
+ )
239
+ # scatter plot rt vs mz
240
+ p = bp.figure(
241
+ width=800,
242
+ height=600,
243
+ title="Consensus map",
244
+ )
245
+ p.xaxis.axis_label = "Retention Time (min)"
246
+ p.yaxis.axis_label = "m/z"
247
+ scatter_renderer = p.scatter(
248
+ x="rt",
249
+ y="mz",
250
+ size="markersize",
251
+ fill_color={"field": colorby, "transform": color_mapper},
252
+ line_color=None,
253
+ alpha=alpha,
254
+ source=source,
255
+ )
256
+ # add hover tool
257
+ hover = HoverTool(
258
+ tooltips=[
259
+ ("consensus_uid", "@consensus_uid"),
260
+ ("consensus_id", "@consensus_id"),
261
+ ("number_samples", "@number_samples"),
262
+ ("number_ms2", "@number_ms2"),
263
+ ("rt", "@rt"),
264
+ ("mz", "@mz"),
265
+ ("inty_mean", "@inty_mean"),
266
+ ("iso_mean", "@iso_mean"),
267
+ ("coherence_mean", "@chrom_coherence_mean"),
268
+ ("prominence_mean", "@chrom_prominence_mean"),
269
+ ],
270
+ renderers=[scatter_renderer],
271
+ )
272
+ p.add_tools(hover)
273
+
274
+ # add colorbar
275
+ color_bar = ColorBar(
276
+ color_mapper=color_mapper,
277
+ label_standoff=12,
278
+ location=(0, 0),
279
+ title=colorby,
280
+ ticker=BasicTicker(desired_num_ticks=8),
281
+ )
282
+ p.add_layout(color_bar, "right")
283
+
284
+ if filename is not None:
285
+ bp.output_file(filename)
286
+ bp.show(p)
287
+ return p
288
+
289
+
290
+ def plot_samples_2d(
291
+ self,
292
+ samples=None,
293
+ filename=None,
294
+ markersize=2,
295
+ size="const",
296
+ alpha_max=0.8,
297
+ alpha="inty",
298
+ cmap="Turbo256",
299
+ max_features=50000, # Reduced default for better performance with many samples
300
+ ):
301
+ """
302
+ Plot all feature maps for sample_uid in parameter uids in an overlaid scatter plot.
303
+ Each sample is a different color. Alpha scales with intensity.
304
+ OPTIMIZED VERSION: Uses vectorized operations and batch processing.
305
+ """
306
+
307
+ sample_uids = self._get_sample_uids(samples)
308
+
309
+ if not sample_uids:
310
+ self.logger.error("No valid sample_uids provided.")
311
+ return
312
+
313
+ colors = Turbo256
314
+ color_map = {uid: colors[i * (256 // max(1, len(sample_uids)))] for i, uid in enumerate(sample_uids)}
315
+
316
+ p = figure(
317
+ width=600,
318
+ height=600,
319
+ title="Sample Features",
320
+ )
321
+ p.xaxis.axis_label = "Retention Time (RT)"
322
+ p.yaxis.axis_label = "m/z"
323
+
324
+ # OPTIMIZATION 1: Batch filter all features for selected samples at once
325
+ features_batch = self.features_df.filter(pl.col("sample_uid").is_in(sample_uids))
326
+
327
+ if features_batch.is_empty():
328
+ self.logger.error("No features found for the selected samples.")
329
+ return
330
+
331
+ # OPTIMIZATION 8: Fast sampling for very large datasets to maintain interactivity
332
+ max_features_per_plot = max_features # Limit for interactive performance
333
+ total_features = len(features_batch)
334
+
335
+ if total_features > max_features_per_plot:
336
+ # OPTIMIZED: Much faster random sampling without groupby operations
337
+ sample_ratio = max_features_per_plot / total_features
338
+ self.logger.info(
339
+ f"Large dataset detected ({total_features:,} features). "
340
+ f"Sampling {sample_ratio:.1%} for visualization performance.",
341
+ )
342
+
343
+ # FAST: Use simple random sampling instead of expensive stratified sampling
344
+ n_samples = min(max_features_per_plot, total_features)
345
+ features_batch = features_batch.sample(n=n_samples, seed=42)
346
+
347
+ # OPTIMIZATION 2: Join with samples_df to get sample names in one operation
348
+ samples_info = self.samples_df.filter(pl.col("sample_uid").is_in(sample_uids))
349
+ features_with_names = features_batch.join(
350
+ samples_info.select(["sample_uid", "sample_name"]),
351
+ on="sample_uid",
352
+ how="left",
353
+ )
354
+
355
+ # OPTIMIZATION 4: Fast pre-calculation of alpha values for all features
356
+ if alpha == "inty":
357
+ # OPTIMIZED: Use efficient Polars operations instead of pandas groupby transform
358
+ # Calculate max intensity per sample in Polars (much faster)
359
+ max_inty_per_sample = features_with_names.group_by("sample_uid").agg(
360
+ pl.col("inty").max().alias("max_inty"),
361
+ )
362
+
363
+ # Join back and calculate alpha efficiently
364
+ features_batch = (
365
+ features_with_names.join(
366
+ max_inty_per_sample,
367
+ on="sample_uid",
368
+ how="left",
369
+ )
370
+ .with_columns(
371
+ (pl.col("inty") / pl.col("max_inty") * alpha_max).alias("alpha"),
372
+ )
373
+ .drop("max_inty")
374
+ )
375
+
376
+ # Convert to pandas once after all Polars operations
377
+ features_pd = features_batch.to_pandas()
378
+ else:
379
+ # Convert to pandas and add constant alpha
380
+ features_pd = features_with_names.to_pandas()
381
+ features_pd["alpha"] = alpha_max
382
+
383
+ # OPTIMIZATION 9: NEW - Batch create all ColumnDataSources at once
384
+ # Group all data by sample_uid and create sources efficiently
385
+ sources = {}
386
+ renderers: list[Any] = []
387
+
388
+ # Pre-compute color mapping to avoid repeated lookups
389
+ color_values = {}
390
+ sample_names = {}
391
+
392
+ for uid in sample_uids:
393
+ sample_data = features_pd[features_pd["sample_uid"] == uid]
394
+ if sample_data.empty:
395
+ continue
396
+
397
+ sample_name = sample_data["sample_name"].iloc[0]
398
+ sample_names[uid] = sample_name
399
+ color_values[uid] = color_map[uid]
400
+
401
+ # OPTIMIZATION 10: Batch renderer creation with pre-computed values
402
+ for uid in sample_uids:
403
+ sample_data = features_pd[features_pd["sample_uid"] == uid]
404
+ if sample_data.empty:
405
+ continue
406
+
407
+ sample_name = sample_names[uid]
408
+ color_values[uid]
409
+
410
+ # OPTIMIZATION 11: Direct numpy array access for better performance
411
+ source = ColumnDataSource(
412
+ data={
413
+ "rt": sample_data["rt"].values,
414
+ "mz": sample_data["mz"].values,
415
+ "inty": sample_data["inty"].values,
416
+ "alpha": sample_data["alpha"].values,
417
+ "sample": np.full(len(sample_data), sample_name, dtype=object),
418
+ },
419
+ )
420
+
421
+ sources[uid] = source
422
+
423
+ # OPTIMIZATION 12: Use pre-computed color value
424
+ # Create renderer with pre-computed values
425
+ renderer: Any
426
+ if size.lower() in ["dyn", "dynamic"]:
427
+ renderer = p.circle(
428
+ x="rt",
429
+ y="mz",
430
+ radius=markersize / 10,
431
+ color=color_values[uid],
432
+ alpha="alpha",
433
+ legend_label=sample_name,
434
+ source=source,
435
+ )
436
+ else:
437
+ renderer = p.scatter(
438
+ x="rt",
439
+ y="mz",
440
+ size=markersize,
441
+ color=color_values[uid],
442
+ alpha="alpha",
443
+ legend_label=sample_name,
444
+ source=source,
445
+ )
446
+ renderers.append(renderer)
447
+
448
+ # OPTIMIZATION 13: Simplified hover tool for better performance with many samples
449
+ if renderers:
450
+ hover = HoverTool(
451
+ tooltips=[
452
+ ("sample", "@sample"),
453
+ ("rt", "@rt{0.00}"),
454
+ ("mz", "@mz{0.0000}"),
455
+ ("intensity", "@inty{0.0e+0}"),
456
+ ],
457
+ renderers=renderers,
458
+ )
459
+ p.add_tools(hover)
460
+
461
+ # Remove legend from plot
462
+ p.legend.visible = False
463
+ if filename:
464
+ if filename.endswith(".html"):
465
+ output_file(filename)
466
+ show(p)
467
+ elif filename.endswith(".png"):
468
+ export_png(p, filename=filename)
469
+ else:
470
+ output_file(filename)
471
+ show(p)
472
+ else:
473
+ show(p)
474
+ return
475
+
476
+
477
+ def plot_chrom(
478
+ self,
479
+ uids=None,
480
+ samples=None,
481
+ filename=None,
482
+ aligned=True,
483
+ width=800,
484
+ height=300,
485
+ ):
486
+ cons_uids = self._get_consensus_uids(uids)
487
+ sample_uids = self._get_sample_uids(samples)
488
+
489
+ chroms = self.get_chrom(uids=cons_uids, samples=sample_uids)
490
+
491
+ if chroms is None or chroms.is_empty():
492
+ self.logger.error("No chromatogram data found.")
493
+ return
494
+
495
+ # Assign a fixed color to each sample/column
496
+ sample_names = [col for col in chroms.columns if col not in ["consensus_uid"]]
497
+ if not sample_names:
498
+ self.logger.error("No sample names found in chromatogram data.")
499
+ return
500
+ color_map = {sample: Turbo256[i * (256 // max(1, len(sample_names)))] for i, sample in enumerate(sample_names)}
501
+
502
+ plots = []
503
+ self.logger.info(f"Plotting {chroms.shape[0]} chromatograms...")
504
+ tdqm_disable = self.log_level not in ["TRACE", "DEBUG", "INFO"]
505
+ for row in tqdm(
506
+ chroms.iter_rows(named=True),
507
+ total=chroms.shape[0],
508
+ desc=f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]} | INFO | {self.log_label}Plot chromatograms",
509
+ disable=tdqm_disable,
510
+ ):
511
+ consensus_uid = row["consensus_uid"] # Get consensus_uid from the row
512
+ consensus_id = consensus_uid # Use the same value for consensus_id
513
+ curves = []
514
+ rt_min = np.inf
515
+ rt_max = 0
516
+ for sample in sample_names:
517
+ chrom = row[sample]
518
+ if chrom is not None:
519
+ # check if chrom is nan
520
+ if isinstance(chrom, float) and np.isnan(chrom):
521
+ continue
522
+
523
+ chrom = chrom.to_dict()
524
+ rt = chrom["rt"].copy()
525
+ if len(rt) == 0:
526
+ continue
527
+ if aligned and "rt_shift" in chrom:
528
+ rt_shift = chrom["rt_shift"]
529
+ if rt_shift is not None:
530
+ # Convert to numpy array if it's a list, then add scalar
531
+ if isinstance(rt, list):
532
+ rt = np.array(rt)
533
+ rt = rt + rt_shift # Add scalar to array
534
+
535
+ # update rt_min and rt_max
536
+ if rt[0] < rt_min:
537
+ rt_min = rt[0]
538
+ if rt[-1] > rt_max:
539
+ rt_max = rt[-1]
540
+
541
+ inty = chrom["inty"]
542
+
543
+ # Convert both rt and inty to numpy arrays if they're lists
544
+ if isinstance(rt, list):
545
+ rt = np.array(rt)
546
+ if isinstance(inty, list):
547
+ inty = np.array(inty)
548
+
549
+ # Ensure both rt and inty are arrays and have the same length and are not empty
550
+ if rt.size > 0 and inty.size > 0 and rt.shape == inty.shape:
551
+ # sort rt and inty by rt
552
+ sorted_indices = np.argsort(rt)
553
+ rt = rt[sorted_indices]
554
+ inty = inty[sorted_indices]
555
+ curve = hv.Curve((rt, inty), kdims=["RT"], vdims=["inty"]).opts(
556
+ color=color_map[sample],
557
+ line_width=1,
558
+ )
559
+ curves.append(curve)
560
+
561
+ if "feature_start" in chrom and "feature_end" in chrom:
562
+ # Add vertical lines for feature start and end
563
+ feature_start = chrom["feature_start"]
564
+ feature_end = chrom["feature_end"]
565
+ if aligned and "rt_shift" in chrom:
566
+ rt_shift = chrom["rt_shift"]
567
+ if rt_shift is not None:
568
+ feature_start += rt_shift
569
+ feature_end += rt_shift
570
+ if feature_start < rt_min:
571
+ rt_min = feature_start
572
+ if feature_end > rt_max:
573
+ rt_max = feature_end
574
+ # Add vertical lines to the curves
575
+ curves.append(
576
+ hv.VLine(feature_start).opts(
577
+ color=color_map[sample],
578
+ line_dash="dotted",
579
+ line_width=1,
580
+ ),
581
+ )
582
+ curves.append(
583
+ hv.VLine(feature_end).opts(
584
+ color=color_map[sample],
585
+ line_dash="dotted",
586
+ line_width=1,
587
+ ),
588
+ )
589
+ if curves:
590
+ # find row in consensus_df with consensus_id
591
+ consensus_row = self.consensus_df.filter(
592
+ pl.col("consensus_uid") == consensus_id,
593
+ )
594
+ rt_start_mean = consensus_row["rt_start_mean"][0]
595
+ rt_end_mean = consensus_row["rt_end_mean"][0]
596
+ # Add vertical lines to overlay
597
+ curves.append(hv.VLine(rt_start_mean).opts(color="black", line_width=2))
598
+ curves.append(hv.VLine(rt_end_mean).opts(color="black", line_width=2))
599
+
600
+ overlay = hv.Overlay(curves).opts(
601
+ height=height,
602
+ width=width,
603
+ title=f"Consensus UID: {consensus_id}, mz: {consensus_row['mz'][0]:.4f}, rt: {consensus_row['rt'][0]:.2f}{' (aligned)' if aligned else ''}",
604
+ xlim=(rt_min, rt_max),
605
+ shared_axes=False,
606
+ )
607
+ plots.append(overlay)
608
+
609
+ if not plots:
610
+ self.logger.warning("No valid chromatogram curves to plot.")
611
+ return
612
+
613
+ # stack vertically.
614
+ # Stack all plots vertically in a Panel column
615
+ layout = panel.Column(*[panel.panel(plot) for plot in plots])
616
+ if filename is not None:
617
+ if filename.endswith(".html"):
618
+ panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
619
+ else:
620
+ # Save as PNG using Panel's export_png if filename ends with .png
621
+ if filename.endswith(".png"):
622
+ from panel.io.save import save_png
623
+
624
+ # Convert Holoviews overlays to Bokeh models before saving
625
+ bokeh_layout = panel.panel(layout).get_root() # type: ignore[attr-defined]
626
+ save_png(bokeh_layout, filename=filename)
627
+ else:
628
+ panel.panel(layout).save(filename, embed=True) # type: ignore[attr-defined]
629
+ else:
630
+ # In a server context, return the panel object instead of showing or saving directly
631
+ # return panel.panel(layout)
632
+ panel.panel(layout).show()