From 64f58aee27966e1afb1150ff81c33a809e74102e Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:30:30 +0530 Subject: [PATCH 01/18] Simplifying MultiPlot Interface #83 --- .../plot_spyogenes_subplots_ms_matplotlib.py | 97 ++++++---------- pyopenms_viz/_config.py | 4 + pyopenms_viz/_core.py | 109 +++++++++++++----- 3 files changed, 117 insertions(+), 93 deletions(-) diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index ec662873..3fd60636 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -1,21 +1,22 @@ """ -Plot Spyogenes subplots ms_matplotlib -======================================= +Plot Spyogenes subplots ms_matplotlib using tile_by +==================================================== -Here we show how we can plot multiple chromatograms across runs together +This script downloads the Spyogenes data and uses the new tile_by parameter to create subplots automatically. """ import pandas as pd import requests import zipfile -import numpy as np import matplotlib.pyplot as plt +import sys +# Append the local module path + +# Set the plotting backend pd.options.plotting.backend = "ms_matplotlib" ###### Load Data ####### - -# URL of the zip file url = "https://github.com/OpenMS/pyopenms_viz/releases/download/v0.1.3/spyogenes.zip" zip_filename = "spyogenes.zip" @@ -24,73 +25,47 @@ print(f"Downloading {zip_filename}...") response = requests.get(url) response.raise_for_status() # Check for any HTTP errors - - # Save the zip file to the current directory with open(zip_filename, "wb") as out: out.write(response.content) print(f"Downloaded {zip_filename} successfully.") -except requests.RequestException as e: +except Exception as e: print(f"Error downloading zip file: {e}") -except IOError as e: - print(f"Error writing zip file: {e}") -# Unzipping the file +# Unzip the file try: with zipfile.ZipFile(zip_filename, "r") as zip_ref: - # Extract all files to the current directory zip_ref.extractall() print("Unzipped files successfully.") -except zipfile.BadZipFile as e: +except Exception as e: print(f"Error unzipping file: {e}") -annotation_bounds = pd.read_csv( - "spyogenes/AADGQTVSGGSILYR3_manual_annotations.tsv", sep="\t" -) # contain annotations across all runs -chrom_df = pd.read_csv( - "spyogenes/chroms_AADGQTVSGGSILYR3.tsv", sep="\t" -) # contains chromatogram for precursor across all runs - -##### Set Plotting Variables ##### -pd.options.plotting.backend = "ms_matplotlib" -RUN_NAMES = [ - "Run #0 Spyogenes 0% human plasma", - "Run #1 Spyogenes 0% human plasma", - "Run #2 Spyogenes 0% human plasma", - "Run #3 Spyogenes 10% human plasma", - "Run #4 Spyogenes 10% human plasma", - "Run #5 Spyogenes 10% human plasma", -] - -fig, axs = plt.subplots(len(np.unique(chrom_df["run"])), 1, figsize=(10, 15)) - -# plt.close ### required for running in jupyter notebook setting - -# For each run fill in the axs object with the corresponding chromatogram -plot_list = [] -for i, run in enumerate(RUN_NAMES): - run_df = chrom_df[chrom_df["run_name"] == run] - current_bounds = annotation_bounds[annotation_bounds["run"] == run] +# Load the data +annotation_bounds = pd.read_csv("spyogenes/AADGQTVSGGSILYR3_manual_annotations.tsv", sep="\t") +chrom_df = pd.read_csv("spyogenes/chroms_AADGQTVSGGSILYR3.tsv", sep="\t") - run_df.plot( - kind="chromatogram", - x="rt", - y="int", - grid=False, - by="ion_annotation", - title=run_df.iloc[0]["run_name"], - title_font_size=16, - xaxis_label_font_size=14, - yaxis_label_font_size=14, - xaxis_tick_font_size=12, - yaxis_tick_font_size=12, - canvas=axs[i], - relative_intensity=True, - annotation_data=current_bounds, - xlabel="Retention Time (sec)", - ylabel="Relative\nIntensity", - annotation_legend_config=dict(show=False), - legend_config={"show": False}, - ) +##### Plotting Using Tile By ##### +# Instead of pre-creating subplots and looping over RUN_NAMES, +# we call the plot method once and provide a tile_by parameter. +fig = chrom_df.plot( + kind="chromatogram", + x="rt", + y="int", + tile_by="run_name", # Automatically groups data by run_name and creates subplots + tile_columns=1, # Layout: 1 column (one subplot per row) + grid=False, + by="ion_annotation", + title_font_size=16, + xaxis_label_font_size=14, + yaxis_label_font_size=14, + xaxis_tick_font_size=12, + yaxis_tick_font_size=12, + relative_intensity=True, + annotation_data=annotation_bounds, + xlabel="Retention Time (sec)", + ylabel="Relative\nIntensity", + annotation_legend_config={"show": False}, + legend_config={"show": False}, +) fig.tight_layout() fig diff --git a/pyopenms_viz/_config.py b/pyopenms_viz/_config.py index 0b79487c..6ed61595 100644 --- a/pyopenms_viz/_config.py +++ b/pyopenms_viz/_config.py @@ -205,6 +205,10 @@ def default_legend_factory(): legend_config: LegendConfig | dict = field(default_factory=default_legend_factory) opacity: float = 1.0 + tile_by: str | None = None # Name of the column to tile the plot by. + tile_columns: int = 1 # How many columns in the subplot grid. + tile_figsize: Tuple[int, int] = (10, 15) # Overall figure size for tiled plots. + def __post_init__(self): # if legend_config is a dictionary, update it to LegendConfig object if isinstance(self.legend_config, dict): diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 62d51c02..7a840eac 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -14,6 +14,10 @@ from pandas.util._decorators import Appender import re +import matplotlib.pyplot as plt +from math import ceil +import numpy as np + from numpy import ceil, log1p, log2, nan, mean, repeat, concatenate from ._config import ( LegendConfig, @@ -539,7 +543,6 @@ def _create_tooltips(self, entries: dict, index: bool = True): class ChromatogramPlot(BaseMSPlot, ABC): - _config: ChromatogramConfig = None @property @@ -560,9 +563,7 @@ def load_config(self, **kwargs): def __init__(self, data, config: ChromatogramConfig = None, **kwargs) -> None: super().__init__(data, config, **kwargs) - self.label_suffix = self.x # set label suffix for bounding box - self._check_and_aggregate_duplicates() # sort data by x so in order @@ -579,45 +580,89 @@ def __init__(self, data, config: ChromatogramConfig = None, **kwargs) -> None: def plot(self): """ - Create the plot + Create the plot. If the configuration includes a valid tile_by column, + the data will be split into subplots based on unique values in that column. """ - tooltip_entries = {"retention time": self.x, "intensity": self.y} - if "Annotation" in self.data.columns: - tooltip_entries["annotation"] = "Annotation" - if "product_mz" in self.data.columns: - tooltip_entries["product m/z"] = "product_mz" - tooltips, custom_hover_data = self._create_tooltips( - tooltip_entries, index=False - ) - - linePlot = self.get_line_renderer(data=self.data, config=self._config) - - self.canvas = linePlot.generate(tooltips, custom_hover_data) - self._modify_y_range((0, self.data[self.y].max()), (0, 0.1)) - - if self._interactive: - self.manual_boundary_renderer = self._add_bounding_vertical_drawer() - - if self.annotation_data is not None: - self._add_peak_boundaries(self.annotation_data) + # Check for tiling functionality + tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None + + if tile_by and tile_by in self.data.columns: + # Group the data by the tile_by column + grouped = self.data.groupby(tile_by) + num_groups = len(grouped) + + # Get tiling options from config + tile_columns = self._config.tile_columns if hasattr(self._config, "tile_columns") else 1 + tile_rows = int(ceil(num_groups / tile_columns)) + figsize = self._config.tile_figsize if hasattr(self._config, "tile_figsize") else (10, 15) + + # Create a figure with a grid of subplots + fig, axes = plt.subplots(tile_rows, tile_columns, figsize=figsize, squeeze=False) + axes = axes.flatten() # Easier indexing for a 1D list + + # Loop through each group and plot on its own axis + for i, (group_val, group_df) in enumerate(grouped): + ax = axes[i] + + # Prepare tooltips for this group (if applicable) + tooltip_entries = {"retention time": self.x, "intensity": self.y} + if "Annotation" in group_df.columns: + tooltip_entries["annotation"] = "Annotation" + if "product_mz" in group_df.columns: + tooltip_entries["product m/z"] = "product_mz" + tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) + + # Get a line renderer instance and generate the plot for the current group, + # passing the current axis (canvas) using a parameter like `canvas` or `ax`. + linePlot = self.get_line_renderer(data=group_df, config=self._config) + # Here, we assume that your renderer can accept the axis to plot on: + linePlot.canvas = ax + linePlot.generate(tooltips, custom_hover_data) + + + # Set the title of this subplot based on the group value + ax.set_title(f"{tile_by}: {group_val}", fontsize=14) + # Optionally adjust the y-axis limits for the subplot + ax.set_ylim(0, group_df[self.y].max()) + + # If you have annotations that should be split, filter them too + if self.annotation_data is not None and tile_by in self.annotation_data.columns: + group_annotations = self.annotation_data[self.annotation_data[tile_by] == group_val] + self._add_peak_boundaries(group_annotations) + + # Remove any extra axes if the grid size is larger than the number of groups + for j in range(i + 1, len(axes)): + fig.delaxes(axes[j]) + + fig.tight_layout() + self.canvas = fig + else: + # Fallback: plot on a single canvas if no valid tiling is specified + tooltip_entries = {"retention time": self.x, "intensity": self.y} + if "Annotation" in self.data.columns: + tooltip_entries["annotation"] = "Annotation" + if "product_mz" in self.data.columns: + tooltip_entries["product m/z"] = "product_mz" + tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) + linePlot = self.get_line_renderer(data=self.data, config=self._config) + self.canvas = linePlot.generate(tooltips, custom_hover_data) + self._modify_y_range((0, self.data[self.y].max()), (0, 0.1)) + + if self._interactive: + self.manual_boundary_renderer = self._add_bounding_vertical_drawer() + if self.annotation_data is not None: + self._add_peak_boundaries(self.annotation_data) def _add_peak_boundaries(self, annotation_data): """ Prepare data for adding peak boundaries to the plot. - This is not a complete method should be overridden by subclasses. - - Args: - annotation_data (DataFrame): The feature data containing the peak boundaries. - - Returns: - None + (Override this method if needed.) """ - # compute the apex intensity self.compute_apex_intensity(annotation_data) def compute_apex_intensity(self, annotation_data): """ - Compute the apex intensity of the peak group based on the peak boundaries + Compute the apex intensity of the peak group based on the peak boundaries. """ for idx, feature in annotation_data.iterrows(): annotation_data.loc[idx, "apexIntensity"] = self.data.loc[ From 9c4a5f145560bb8687d6457034ffb49597d20800 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Wed, 26 Mar 2025 14:57:57 +0530 Subject: [PATCH 02/18] Simplifying MultiPlot Interface #83-2 --- .../plot_spyogenes_subplots_ms_matplotlib.py | 1 + pyopenms_viz/_core.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index 3fd60636..b97cf7c2 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -12,6 +12,7 @@ import sys # Append the local module path +sys.path.append("c:/Users/ACER/multiplot_interface/pyopenms_viz") # Set the plotting backend pd.options.plotting.backend = "ms_matplotlib" diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 7a840eac..1eb2ba61 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -16,7 +16,6 @@ import matplotlib.pyplot as plt from math import ceil -import numpy as np from numpy import ceil, log1p, log2, nan, mean, repeat, concatenate from ._config import ( From 4e9485c5e1524c66080a4771278fab1c5a359bc3 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Wed, 26 Mar 2025 16:52:50 +0530 Subject: [PATCH 03/18] Simplifying MultiPlot Interface #83-3 --- .../plot_spyogenes_subplots_ms_matplotlib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index b97cf7c2..4075c759 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -11,8 +11,6 @@ import matplotlib.pyplot as plt import sys -# Append the local module path -sys.path.append("c:/Users/ACER/multiplot_interface/pyopenms_viz") # Set the plotting backend pd.options.plotting.backend = "ms_matplotlib" From 0f478a5d8c55d91a5185a027313dcf27b43e5047 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Thu, 27 Mar 2025 07:36:53 +0530 Subject: [PATCH 04/18] Simplifying MultiPlot Interface #83-3(enhancing plot function in SpectrumPlot) --- ...estigate_spectrum_binning_ms_matplotlib.py | 4 +- pyopenms_viz/_core.py | 211 ++++++++++-------- 2 files changed, 118 insertions(+), 97 deletions(-) diff --git a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py index ed0c190d..d83837c9 100644 --- a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py @@ -78,7 +78,7 @@ i = j = 0 for params in params_list: p = df.plot( - kind="spectrum", x="mz", y="intensity", canvas=axs[i][j], grid=False, **params + kind="spectrum", x="mz", y="intensity", canvas=axs[i][j], grid=False, show_plot=False, **params ) j += 1 if j >= 2: # If we've filled two columns, move to the next row @@ -86,4 +86,4 @@ i += 1 fig.tight_layout() -fig.show() +plt.show() \ No newline at end of file diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 1eb2ba61..e8cb05f8 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -792,103 +792,124 @@ def _computed_num_bins(self): def plot(self): """Standard spectrum plot with m/z on x-axis, intensity on y-axis and optional mirror spectrum.""" + + # Check if tiling is requested + tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None + if tile_by and tile_by in self.data.columns: + # Group data by tile_by column + grouped = self.data.groupby(tile_by) + num_groups = len(grouped) + from math import ceil + tile_columns = self._config.tile_columns if hasattr(self._config, "tile_columns") else 1 + tile_rows = ceil(num_groups / tile_columns) + figsize = self._config.tile_figsize if hasattr(self._config, "tile_figsize") else (10, 15) + fig, axes = plt.subplots(tile_rows, tile_columns, figsize=figsize, squeeze=False) + axes = axes.flatten() - # Prepare data - spectrum = self._prepare_data(self.data) - if self.reference_spectrum is not None: - reference_spectrum = self._prepare_data(self.reference_spectrum) + for i, (group_val, group_df) in enumerate(grouped): + # Prepare group-specific spectrum data + group_spectrum = self._prepare_data(group_df) + # If reference spectrum exists and tile_by is present in it, filter accordingly. + if self.reference_spectrum is not None and tile_by in self.reference_spectrum.columns: + group_reference = self._prepare_data(self.reference_spectrum[self.reference_spectrum[tile_by] == group_val]) + else: + group_reference = None + + # Prepare tooltips + entries = {"m/z": self.x, "intensity": self.y} + for optional in ("native_id", self.ion_annotation, self.sequence_annotation): + if optional in group_df.columns: + entries[optional.replace("_", " ")] = optional + tooltips, custom_hover_data = self._create_tooltips(entries=entries, index=False) + + # Determine grouping for color generation + if self.peak_color is not None and self.peak_color in group_df.columns: + self.by = self.peak_color + elif self.ion_annotation is not None and self.ion_annotation in group_df.columns: + self.by = self.ion_annotation + + # Get annotations for this group + ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_spectrum, self.x, self.y) + # Convert group spectrum to line plot format + group_spectrum = self.convert_for_line_plots(group_spectrum, self.x, self.y) + self.color = self._get_colors(group_spectrum, kind="peak") + spectrumPlot = self.get_line_renderer(data=group_spectrum, by=self.by, color=self.color, config=self._config) + # Set current axis as canvas using property setter + spectrumPlot.canvas = axes[i] + spectrumPlot.generate(tooltips, custom_hover_data) + spectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) + axes[i].set_title(f"{tile_by}: {group_val}", fontsize=14) + + # (Optional: handle mirror spectrum for each group similarly) + if self.mirror_spectrum and group_reference is not None: + # Set intensity to negative values + group_reference[self.y] = group_reference[self.y] * -1 + color_mirror = self._get_colors(group_reference, kind="peak") + group_reference = self.convert_for_line_plots(group_reference, self.x, self.y) + _, reference_custom_hover_data = self.get_spectrum_tooltip_data(group_reference, self.x, self.y) + mirrorSpectrumPlot = self.get_line_renderer(data=group_reference, color=color_mirror, config=self._config) + mirrorSpectrumPlot.canvas = axes[i] + mirrorSpectrumPlot.generate(None, None) + # Add mirror annotations + ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_reference, self.x, self.y) + mirrorSpectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) + + # Optionally, adjust x/y ranges for the current axis + # e.g., axes[i].set_xlim(...), axes[i].set_ylim(...) + + # Remove any extra axes if present + for j in range(i + 1, len(axes)): + fig.delaxes(axes[j]) + fig.tight_layout() + self.canvas = fig else: - reference_spectrum = None - - entries = {"m/z": self.x, "intensity": self.y} - for optional in ( - "native_id", - self.ion_annotation, - self.sequence_annotation, - ): - if optional in self.data.columns: - entries[optional.replace("_", " ")] = optional - - tooltips, custom_hover_data = self._create_tooltips( - entries=entries, index=False - ) - - # color generation is more complex for spectrum plots, so it has its own methods - - # Peak colors are determined by peak_color column (highest priorty) or ion_annotation column (second priority) or "by" column (lowest priority) - if self.peak_color is not None and self.peak_color in self.data.columns: - self.by = self.peak_color - elif ( - self.ion_annotation is not None and self.ion_annotation in self.data.columns - ): - self.by = self.ion_annotation - - # Annotations for spectrum - ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations( - spectrum, self.x, self.y - ) - - # Convert to line plot format - spectrum = self.convert_for_line_plots(spectrum, self.x, self.y) - - self.color = self._get_colors(spectrum, kind="peak") - spectrumPlot = self.get_line_renderer( - data=spectrum, by=self.by, color=self.color, config=self._config - ) - self.canvas = spectrumPlot.generate(tooltips, custom_hover_data) - spectrumPlot._add_annotations( - self.canvas, ann_texts, ann_xs, ann_ys, ann_colors - ) - - # Mirror spectrum - if self.mirror_spectrum and self.reference_spectrum is not None: - ## create a mirror spectrum - # Set intensity to negative values - reference_spectrum[self.y] = reference_spectrum[self.y] * -1 - - color_mirror = self._get_colors(reference_spectrum, kind="peak") - reference_spectrum = self.convert_for_line_plots( - reference_spectrum, self.x, self.y - ) - - _, reference_custom_hover_data = self.get_spectrum_tooltip_data( - reference_spectrum, self.x, self.y - ) - mirrorSpectrumPlot = self.get_line_renderer( - data=reference_spectrum, color=color_mirror, config=self._config - ) - - mirrorSpectrumPlot.generate(None, None) - - # Annotations for reference spectrum - ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations( - reference_spectrum, self.x, self.y - ) - mirrorSpectrumPlot._add_annotations( - self.canvas, ann_texts, ann_xs, ann_ys, ann_colors - ) - - # Plot horizontal line to hide connection between peaks - self.plot_x_axis_line(self.canvas, line_width=2) - - # Adjust x axis padding (Plotly cuts outermost peaks) - min_values = [spectrum[self.x].min()] - max_values = [spectrum[self.x].max()] - if reference_spectrum is not None: - min_values.append(reference_spectrum[self.x].min()) - max_values.append(reference_spectrum[self.x].max()) - self._modify_x_range((min(min_values), max(max_values)), padding=(0.20, 0.20)) - # Adjust y axis padding (annotations should stay inside plot) - max_value = spectrum[self.y].max() - min_value = 0 - min_padding = 0 - max_padding = 0.15 - if reference_spectrum is not None and self.mirror_spectrum: - min_value = reference_spectrum[self.y].min() - min_padding = -0.2 - max_padding = 0.4 - - self._modify_y_range((min_value, max_value), padding=(min_padding, max_padding)) + # Fallback to default single plot behavior + # [Existing code remains unchanged] + spectrum = self._prepare_data(self.data) + if self.reference_spectrum is not None: + reference_spectrum = self._prepare_data(self.reference_spectrum) + else: + reference_spectrum = None + entries = {"m/z": self.x, "intensity": self.y} + for optional in ("native_id", self.ion_annotation, self.sequence_annotation): + if optional in self.data.columns: + entries[optional.replace("_", " ")] = optional + tooltips, custom_hover_data = self._create_tooltips(entries=entries, index=False) + if self.peak_color is not None and self.peak_color in self.data.columns: + self.by = self.peak_color + elif self.ion_annotation is not None and self.ion_annotation in self.data.columns: + self.by = self.ion_annotation + ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(spectrum, self.x, self.y) + spectrum = self.convert_for_line_plots(spectrum, self.x, self.y) + self.color = self._get_colors(spectrum, kind="peak") + spectrumPlot = self.get_line_renderer(data=spectrum, by=self.by, color=self.color, config=self._config) + self.canvas = spectrumPlot.generate(tooltips, custom_hover_data) + spectrumPlot._add_annotations(self.canvas, ann_texts, ann_xs, ann_ys, ann_colors) + if self.mirror_spectrum and self.reference_spectrum is not None: + reference_spectrum[self.y] = reference_spectrum[self.y] * -1 + color_mirror = self._get_colors(reference_spectrum, kind="peak") + reference_spectrum = self.convert_for_line_plots(reference_spectrum, self.x, self.y) + _, reference_custom_hover_data = self.get_spectrum_tooltip_data(reference_spectrum, self.x, self.y) + mirrorSpectrumPlot = self.get_line_renderer(data=reference_spectrum, color=color_mirror, config=self._config) + mirrorSpectrumPlot.generate(None, None) + ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(reference_spectrum, self.x, self.y) + mirrorSpectrumPlot._add_annotations(self.canvas, ann_texts, ann_xs, ann_ys, ann_colors) + self.plot_x_axis_line(self.canvas, line_width=2) + min_values = [spectrum[self.x].min()] + max_values = [spectrum[self.x].max()] + if reference_spectrum is not None: + min_values.append(reference_spectrum[self.x].min()) + max_values.append(reference_spectrum[self.x].max()) + self._modify_x_range((min(min_values), max(max_values)), padding=(0.20, 0.20)) + max_value = spectrum[self.y].max() + min_value = 0 + min_padding = 0 + max_padding = 0.15 + if reference_spectrum is not None and self.mirror_spectrum: + min_value = reference_spectrum[self.y].min() + min_padding = -0.2 + max_padding = 0.4 + self._modify_y_range((min_value, max_value), padding=(min_padding, max_padding)) def _bin_peaks(self, df: DataFrame) -> DataFrame: """ From f873b4356fbe0b7e4c50f1242f22f92b8140ff25 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:37:51 +0530 Subject: [PATCH 05/18] Simplifying MultiPlot Interface #83-resolving_conflick-1 --- ...estigate_spectrum_binning_ms_matplotlib.py | 3 + .../plot_spyogenes_subplots_ms_matplotlib.py | 14 +- pyopenms_viz/__init__.py | 1 + pyopenms_viz/_config.py | 1 - pyopenms_viz/_core.py | 336 +++++++----------- 5 files changed, 152 insertions(+), 203 deletions(-) diff --git a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py index d83837c9..e754b90f 100644 --- a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py @@ -9,6 +9,9 @@ import matplotlib.pyplot as plt import requests from io import StringIO +import sys +import os +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) pd.options.plotting.backend = "ms_matplotlib" diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index 4075c759..01f47d04 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -1,8 +1,8 @@ """ -Plot Spyogenes subplots ms_matplotlib using tile_by +Plot Spyogenes subplots ms_matplotlib ==================================================== -This script downloads the Spyogenes data and uses the new tile_by parameter to create subplots automatically. +Here we show how we can plot multiple chromatograms across runs together """ import pandas as pd @@ -10,24 +10,30 @@ import zipfile import matplotlib.pyplot as plt import sys +import sys +import os +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) # Set the plotting backend pd.options.plotting.backend = "ms_matplotlib" ###### Load Data ####### + +# URL of the zip file url = "https://github.com/OpenMS/pyopenms_viz/releases/download/v0.1.3/spyogenes.zip" zip_filename = "spyogenes.zip" # Download the zip file try: + # Save the zip file to the current directory print(f"Downloading {zip_filename}...") response = requests.get(url) response.raise_for_status() # Check for any HTTP errors with open(zip_filename, "wb") as out: out.write(response.content) print(f"Downloaded {zip_filename} successfully.") -except Exception as e: +except requests.RequestException as e: print(f"Error downloading zip file: {e}") # Unzip the file @@ -35,7 +41,7 @@ with zipfile.ZipFile(zip_filename, "r") as zip_ref: zip_ref.extractall() print("Unzipped files successfully.") -except Exception as e: +except zipfile.BadZipFile as e: print(f"Error unzipping file: {e}") # Load the data diff --git a/pyopenms_viz/__init__.py b/pyopenms_viz/__init__.py index e822cc9f..e43e0ee2 100644 --- a/pyopenms_viz/__init__.py +++ b/pyopenms_viz/__init__.py @@ -119,6 +119,7 @@ def _get_call_args(backend_name: str, data: DataFrame, args, kwargs): ("feature_config", None), ("_config", None), ("backend", backend_name), + # ("tile_by", None), ] else: raise ValueError( diff --git a/pyopenms_viz/_config.py b/pyopenms_viz/_config.py index 6ed61595..d2afca6a 100644 --- a/pyopenms_viz/_config.py +++ b/pyopenms_viz/_config.py @@ -207,7 +207,6 @@ def default_legend_factory(): tile_by: str | None = None # Name of the column to tile the plot by. tile_columns: int = 1 # How many columns in the subplot grid. - tile_figsize: Tuple[int, int] = (10, 15) # Overall figure size for tiled plots. def __post_init__(self): # if legend_config is a dictionary, update it to LegendConfig object diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index e8cb05f8..6c88203b 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -541,6 +541,10 @@ def _create_tooltips(self, entries: dict, index: bool = True): pass +from math import ceil +import matplotlib.pyplot as plt +import warnings + class ChromatogramPlot(BaseMSPlot, ABC): _config: ChromatogramConfig = None @@ -565,45 +569,65 @@ def __init__(self, data, config: ChromatogramConfig = None, **kwargs) -> None: self.label_suffix = self.x # set label suffix for bounding box self._check_and_aggregate_duplicates() - # sort data by x so in order + # Sort data by x (and by self.by if provided) so the data is in order. if self.by is not None: self.data.sort_values(by=[self.by, self.x], inplace=True) else: self.data.sort_values(by=self.x, inplace=True) - # Convert to relative intensity if required + # Convert to relative intensity if required. if self.relative_intensity: self.data[self.y] = self.data[self.y] / self.data[self.y].max() * 100 + # Perform all validations for the plotting configuration. + self._validate_plot_config() + + # Proceed to generate the plot. self.plot() + def _validate_plot_config(self): + """ + Validate plot configuration options (e.g., tiling parameters) before plotting. + """ + # Validate the tile_by option: check if the specified column exists in the data. + if hasattr(self._config, "tile_by"): + tile_by = self._config.tile_by + if tile_by not in self.data.columns: + warnings.warn( + f"tile_by column '{tile_by}' not found in data. Plot will be generated without tiling." + ) + self._config.tile_by = None + + # Validate tile_columns: ensure it is a positive integer. + if hasattr(self._config, "tile_columns"): + if not isinstance(self._config.tile_columns, int) or self._config.tile_columns < 1: + warnings.warn("tile_columns must be a positive integer. Defaulting to 1.") + self._config.tile_columns = 1 + def plot(self): """ - Create the plot. If the configuration includes a valid tile_by column, - the data will be split into subplots based on unique values in that column. + Create the plot using the validated configuration. """ - # Check for tiling functionality tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None - if tile_by and tile_by in self.data.columns: - # Group the data by the tile_by column + if tile_by: + # Group the data by the tile_by column. grouped = self.data.groupby(tile_by) num_groups = len(grouped) - # Get tiling options from config - tile_columns = self._config.tile_columns if hasattr(self._config, "tile_columns") else 1 - tile_rows = int(ceil(num_groups / tile_columns)) - figsize = self._config.tile_figsize if hasattr(self._config, "tile_figsize") else (10, 15) + # Use tiling options from the configuration and set instance properties. + self.tile_columns = self._config.tile_columns # e.g. default value set in _config + self.tile_rows = int(ceil(num_groups / self.tile_columns)) - # Create a figure with a grid of subplots - fig, axes = plt.subplots(tile_rows, tile_columns, figsize=figsize, squeeze=False) - axes = axes.flatten() # Easier indexing for a 1D list + # Create a figure with a grid of subplots. + fig, axes = plt.subplots(self.tile_rows, self.tile_columns, squeeze=False) + axes = axes.flatten() # Flatten for easier indexing. - # Loop through each group and plot on its own axis + # Loop through each group and generate the corresponding subplot. for i, (group_val, group_df) in enumerate(grouped): ax = axes[i] - # Prepare tooltips for this group (if applicable) + # Prepare tooltips for this group (if applicable). tooltip_entries = {"retention time": self.x, "intensity": self.y} if "Annotation" in group_df.columns: tooltip_entries["annotation"] = "Annotation" @@ -611,32 +635,29 @@ def plot(self): tooltip_entries["product m/z"] = "product_mz" tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) - # Get a line renderer instance and generate the plot for the current group, - # passing the current axis (canvas) using a parameter like `canvas` or `ax`. + # Get a line renderer instance and generate the plot for the current group. linePlot = self.get_line_renderer(data=group_df, config=self._config) - # Here, we assume that your renderer can accept the axis to plot on: linePlot.canvas = ax linePlot.generate(tooltips, custom_hover_data) - - # Set the title of this subplot based on the group value + # Set the title of the subplot based on the group value. ax.set_title(f"{tile_by}: {group_val}", fontsize=14) - # Optionally adjust the y-axis limits for the subplot + # Adjust the y-axis limits for the subplot. ax.set_ylim(0, group_df[self.y].max()) - # If you have annotations that should be split, filter them too + # Add annotations if available. if self.annotation_data is not None and tile_by in self.annotation_data.columns: group_annotations = self.annotation_data[self.annotation_data[tile_by] == group_val] self._add_peak_boundaries(group_annotations) - # Remove any extra axes if the grid size is larger than the number of groups + # Remove any extra axes if the grid size is larger than the number of groups. for j in range(i + 1, len(axes)): fig.delaxes(axes[j]) fig.tight_layout() self.canvas = fig else: - # Fallback: plot on a single canvas if no valid tiling is specified + # Fallback: plot on a single canvas if tiling is not specified. tooltip_entries = {"retention time": self.x, "intensity": self.y} if "Annotation" in self.data.columns: tooltip_entries["annotation"] = "Annotation" @@ -693,6 +714,16 @@ def plot(self): self._modify_y_range((0, self.data[self.y].max()), (0, 0.1)) +import warnings +from math import ceil +from typing import List, Literal +import re +import numpy as np +from pandas import DataFrame, concat +from numpy import nan +from statistics import mean +# Import your other necessary modules (e.g., for ColorGenerator, mz_tolerance_binning, sturges_rule, freedman_diaconis_rule) + class SpectrumPlot(BaseMSPlot, ABC): @property @@ -702,35 +733,48 @@ def _kind(self): @property def known_columns(self) -> List[str]: """ - List of known columns in the data, if there are duplicates outside of these columns they will be grouped in aggregation if specified + List of known columns in the data. Any duplicates outside these columns + will be grouped in aggregation if specified. """ known_columns = super().known_columns known_columns.extend([self.peak_color] if self.peak_color is not None else []) - known_columns.extend( - [self.ion_annotation] if self.ion_annotation is not None else [] - ) - known_columns.extend( - [self.sequence_annotation] if self.sequence_annotation is not None else [] - ) - known_columns.extend( - [self.custom_annotation] if self.custom_annotation is not None else [] - ) - known_columns.extend( - [self.annotation_color] if self.annotation_color is not None else [] - ) + known_columns.extend([self.ion_annotation] if self.ion_annotation is not None else []) + known_columns.extend([self.sequence_annotation] if self.sequence_annotation is not None else []) + known_columns.extend([self.custom_annotation] if self.custom_annotation is not None else []) + known_columns.extend([self.annotation_color] if self.annotation_color is not None else []) return known_columns @property def _configClass(self): return SpectrumConfig - def __init__( - self, - data, - **kwargs, - ) -> None: + def __init__(self, data, **kwargs) -> None: super().__init__(data, **kwargs) + + # (Other validations like _check_and_aggregate_duplicates, sorting, etc.) + self._check_and_aggregate_duplicates() + + # Sort data by x (and by self.by if provided) + if self.by is not None: + self.data.sort_values(by=[self.by, self.x], inplace=True) + else: + self.data.sort_values(by=self.x, inplace=True) + + # Convert to relative intensity if required. + if self.relative_intensity: + self.data[self.y] = self.data[self.y] / self.data[self.y].max() * 100 + + # Validate tiling configuration in the constructor + if hasattr(self._config, "tile_by"): + tile_by = self._config.tile_by + if tile_by not in self.data.columns: + warnings.warn( + f"tile_by column '{tile_by}' not found in data. Plot will be generated without tiling." + ) + self._config.tile_by = None + # (Other configuration validations can be added here as needed.) + # Proceed to generate the plot self.plot() def load_config(self, **kwargs): @@ -743,7 +787,6 @@ def load_config(self, **kwargs): def _check_and_aggregate_duplicates(self): super()._check_and_aggregate_duplicates() - if self.reference_spectrum is not None: if self.reference_spectrum[self.known_columns].duplicated().any(): if self.aggregate_duplicates: @@ -761,7 +804,8 @@ def _check_and_aggregate_duplicates(self): @property def _peak_bins(self): """ - Get a list of intervals to use in bins. Here bins are not evenly spaced. Currently this only occurs in mz-tol setting + Get a list of intervals to use in bins. + Currently only used for the mz-tol setting. """ if self.bin_method == "mz-tol-bin" and self.bin_peaks == "auto": return mz_tolerance_binning(self.data, self.x, self.mz_tol) @@ -772,7 +816,6 @@ def _peak_bins(self): def _computed_num_bins(self): """ Compute the number of bins based on the number of peaks in the data. - Returns: int: The number of bins. """ @@ -785,7 +828,7 @@ def _computed_num_bins(self): return None elif self.bin_method == "none": return self.num_x_bins - else: # throw error if bin_method is not recognized + else: raise ValueError(f"bin_method {self.bin_method} not recognized") else: return self.num_x_bins @@ -793,29 +836,33 @@ def _computed_num_bins(self): def plot(self): """Standard spectrum plot with m/z on x-axis, intensity on y-axis and optional mirror spectrum.""" - # Check if tiling is requested + # Since tile_by was validated in __init__, we can assume that if set, the column exists. tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None - if tile_by and tile_by in self.data.columns: + if tile_by: # Group data by tile_by column grouped = self.data.groupby(tile_by) num_groups = len(grouped) - from math import ceil - tile_columns = self._config.tile_columns if hasattr(self._config, "tile_columns") else 1 - tile_rows = ceil(num_groups / tile_columns) - figsize = self._config.tile_figsize if hasattr(self._config, "tile_figsize") else (10, 15) - fig, axes = plt.subplots(tile_rows, tile_columns, figsize=figsize, squeeze=False) + + # Assign tiling properties to the instance + self.tile_columns = self._config.tile_columns + self.tile_rows = ceil(num_groups / self.tile_columns) + + # Create a figure with a grid of subplots + fig, axes = plt.subplots(self.tile_rows, self.tile_columns, squeeze=False) axes = axes.flatten() for i, (group_val, group_df) in enumerate(grouped): # Prepare group-specific spectrum data group_spectrum = self._prepare_data(group_df) - # If reference spectrum exists and tile_by is present in it, filter accordingly. + # Filter reference spectrum for current group if applicable. if self.reference_spectrum is not None and tile_by in self.reference_spectrum.columns: - group_reference = self._prepare_data(self.reference_spectrum[self.reference_spectrum[tile_by] == group_val]) + group_reference = self._prepare_data( + self.reference_spectrum[self.reference_spectrum[tile_by] == group_val] + ) else: group_reference = None - # Prepare tooltips + # Prepare tooltips for this group entries = {"m/z": self.x, "intensity": self.y} for optional in ("native_id", self.ion_annotation, self.sequence_annotation): if optional in group_df.columns: @@ -828,21 +875,18 @@ def plot(self): elif self.ion_annotation is not None and self.ion_annotation in group_df.columns: self.by = self.ion_annotation - # Get annotations for this group + # Get annotations for this group and convert data for line plot ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_spectrum, self.x, self.y) - # Convert group spectrum to line plot format group_spectrum = self.convert_for_line_plots(group_spectrum, self.x, self.y) self.color = self._get_colors(group_spectrum, kind="peak") spectrumPlot = self.get_line_renderer(data=group_spectrum, by=self.by, color=self.color, config=self._config) - # Set current axis as canvas using property setter spectrumPlot.canvas = axes[i] spectrumPlot.generate(tooltips, custom_hover_data) spectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) axes[i].set_title(f"{tile_by}: {group_val}", fontsize=14) - # (Optional: handle mirror spectrum for each group similarly) + # Handle mirror spectrum if applicable if self.mirror_spectrum and group_reference is not None: - # Set intensity to negative values group_reference[self.y] = group_reference[self.y] * -1 color_mirror = self._get_colors(group_reference, kind="peak") group_reference = self.convert_for_line_plots(group_reference, self.x, self.y) @@ -850,21 +894,16 @@ def plot(self): mirrorSpectrumPlot = self.get_line_renderer(data=group_reference, color=color_mirror, config=self._config) mirrorSpectrumPlot.canvas = axes[i] mirrorSpectrumPlot.generate(None, None) - # Add mirror annotations ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_reference, self.x, self.y) mirrorSpectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) - - # Optionally, adjust x/y ranges for the current axis - # e.g., axes[i].set_xlim(...), axes[i].set_ylim(...) - - # Remove any extra axes if present + + # Remove any extra axes if present and finalize layout for j in range(i + 1, len(axes)): fig.delaxes(axes[j]) fig.tight_layout() self.canvas = fig else: - # Fallback to default single plot behavior - # [Existing code remains unchanged] + # Fallback: single plot behavior if tiling is not requested spectrum = self._prepare_data(self.data) if self.reference_spectrum is not None: reference_spectrum = self._prepare_data(self.reference_spectrum) @@ -875,6 +914,7 @@ def plot(self): if optional in self.data.columns: entries[optional.replace("_", " ")] = optional tooltips, custom_hover_data = self._create_tooltips(entries=entries, index=False) + if self.peak_color is not None and self.peak_color in self.data.columns: self.by = self.peak_color elif self.ion_annotation is not None and self.ion_annotation in self.data.columns: @@ -914,65 +954,31 @@ def plot(self): def _bin_peaks(self, df: DataFrame) -> DataFrame: """ Bin peaks based on x-axis values. - - Args: - data (DataFrame): The data to bin. - x (str): The column name for the x-axis data. - y (str): The column name for the y-axis data. - - Returns: - DataFrame: The binned data. """ - - # if _peak_bins is set that they are used as the bins over the num_bins parameter if self._peak_bins is not None: - # Function to assign each value to a bin def assign_bin(value): for low, high in self._peak_bins: if low <= value <= high: return f"{low:.4f}-{high:.4f}" - return nan # For values that don't fall into any bin - - # Apply the binning + return nan df[self.x] = df[self.x].apply(assign_bin) - else: # use computed number of bins, bins evenly spaced + else: bins = np.histogram_bin_edges(df[self.x], self._computed_num_bins) - def assign_bin(value): for low_idx in range(len(bins) - 1): if bins[low_idx] <= value <= bins[low_idx + 1]: return f"{bins[low_idx]:.4f}-{bins[low_idx + 1]:.4f}" - return nan # For values that don't fall into any bin - - # Apply the binning + return nan df[self.x] = df[self.x].apply(assign_bin) - - # TODO I am not sure why "cut" method seems to be failing with plotly so created a workaround for now - # error is that object is not JSON serializable because of Interval type - # df[self.x] = cut(df[self.x], bins=self._computed_num_bins) - - # TODO: Find a better way to retain other columns + # Retain other columns cols = [self.x] - if self.by is not None: - cols.append(self.by) - if self.peak_color is not None: - cols.append(self.peak_color) - if self.ion_annotation is not None: - cols.append(self.ion_annotation) - if self.sequence_annotation is not None: - cols.append(self.sequence_annotation) - if self.custom_annotation is not None: - cols.append(self.custom_annotation) - if self.annotation_color is not None: - cols.append(self.annotation_color) - - # Group by x bins and calculate the sum intensity within each bin - df = ( - df.groupby(cols, observed=True) - .agg({self.y: self.aggregation_method}) - .reset_index() - ) - + if self.by is not None: cols.append(self.by) + if self.peak_color is not None: cols.append(self.peak_color) + if self.ion_annotation is not None: cols.append(self.ion_annotation) + if self.sequence_annotation is not None: cols.append(self.sequence_annotation) + if self.custom_annotation is not None: cols.append(self.custom_annotation) + if self.annotation_color is not None: cols.append(self.annotation_color) + df = df.groupby(cols, observed=True).agg({self.y: self.aggregation_method}).reset_index() def convert_to_numeric(value): if isinstance(value, Interval): return value.mid @@ -980,94 +986,52 @@ def convert_to_numeric(value): return mean([float(i) for i in value.split("-")]) else: return value - df[self.x] = df[self.x].apply(convert_to_numeric).astype(float) - - df = df.fillna(0) - return df + return df.fillna(0) def _prepare_data(self, df, label_suffix=""): """ - Prepare data for plotting based on configuration (relative intensity, bin peaks) - - Args: - df (DataFrame): The data to prepare. - label_suffix (str, optional): The suffix to add to the label. Defaults to "", Only for plotly backend - - Returns: - DataFrame: The prepared data. + Prepare data for plotting based on configuration (e.g. relative intensity, bin peaks) """ - - # Convert to relative intensity if required if self.relative_intensity or self.mirror_spectrum: df[self.y] = df[self.y] / df[self.y].max() * 100 - - # Bin peaks if required - if self.bin_peaks == True or (self.bin_peaks == "auto"): + if self.bin_peaks == True or self.bin_peaks == "auto": df = self._bin_peaks(df) - return df - def _get_colors( - self, data: DataFrame, kind: Literal["peak", "annotation"] | None = None - ): + def _get_colors(self, data: DataFrame, kind: Literal["peak", "annotation"] | None = None): """Get color generators for peaks or annotations based on config.""" if kind == "annotation": - # Custom annotating colors with top priority - if ( - self.annotation_color is not None - and self.annotation_color in data.columns - ): + if self.annotation_color is not None and self.annotation_color in data.columns: return ColorGenerator(data[self.annotation_color]) - # Ion annotation colors - elif ( - self.ion_annotation is not None and self.ion_annotation in data.columns - ): - # Generate colors based on ion annotations - return ColorGenerator( - self._get_ion_color_annotation(data[self.ion_annotation]) - ) - # Grouped by colors (from default color map) + elif self.ion_annotation is not None and self.ion_annotation in data.columns: + return ColorGenerator(self._get_ion_color_annotation(data[self.ion_annotation])) elif self.by is not None: - # Get unique values to determine number of distinct colors uniques = data[self.by].unique() color_gen = ColorGenerator() - # Generate a list of colors equal to the number of unique values colors = [next(color_gen) for _ in range(len(uniques))] - # Create a mapping of unique values to their corresponding colors color_map = {uniques[i]: colors[i] for i in range(len(colors))} - # Apply the color mapping to the specified column in the data and turn it into a ColorGenerator return ColorGenerator(data[self.by].apply(lambda x: color_map[x])) - # Fallback ColorGenerator with one color return ColorGenerator(n=1) - else: # Peaks + else: if self.by: uniques = data[self.by].unique().tolist() - # Custom colors with top priority if self.peak_color is not None: return ColorGenerator(uniques) - # Colors based on ion annotation for peaks and annotation text if self.ion_annotation is not None and self.peak_color is None: return ColorGenerator(self._get_ion_color_annotation(uniques)) - # Else just use default colors return ColorGenerator() def _get_annotations(self, data: DataFrame, x: str, y: str): - """Create annotations for each peak. Return lists of texts, x and y locations and colors.""" - + """Create annotations for each peak.""" data["color"] = ["black" for _ in range(len(data))] - ann_texts = [] top_n = self.annotate_top_n_peaks if top_n == "all": top_n = len(data) elif top_n is None: top_n = 0 - # sort values for top intensity peaks on top (ascending for reference spectra with negative values) - data = data.sort_values( - y, ascending=True if data[y].min() < 0 else False - ).reset_index() - + data = data.sort_values(y, ascending=True if data[y].min() < 0 else False).reset_index() for i, row in data.iterrows(): texts = [] if i < top_n: @@ -1075,10 +1039,7 @@ def _get_annotations(self, data: DataFrame, x: str, y: str): texts.append(str(round(row[x], 4))) if self.ion_annotation and self.ion_annotation in data.columns: texts.append(str(row[self.ion_annotation])) - if ( - self.sequence_annotation - and self.sequence_annotation in data.columns - ): + if self.sequence_annotation and self.sequence_annotation in data.columns: texts.append(str(row[self.sequence_annotation])) if self.custom_annotation and self.custom_annotation in data.columns: texts.append(str(row[self.custom_annotation])) @@ -1086,26 +1047,20 @@ def _get_annotations(self, data: DataFrame, x: str, y: str): return ann_texts, data[x].tolist(), data[y].tolist(), data["color"].tolist() def _get_ion_color_annotation(self, ion_annotations: str) -> str: - """Retrieve the color associated with a specific ion annotation from a predefined colormap.""" + """Retrieve the color associated with an ion annotation.""" colormap = { "a": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.PURPLE], "b": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.BLUE], - "c": ColorGenerator.color_blind_friendly_map[ - ColorGenerator.Colors.LIGHTBLUE - ], + "c": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.LIGHTBLUE], "x": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.YELLOW], "y": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.RED], "z": ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.ORANGE], } - def get_ion_color(ion): if isinstance(ion, str): for key in colormap.keys(): - # Exact matches if ion == key: return colormap[key] - # Fragment ions via regex - ## Check if ion format is a1+, a1-, etc. or if it's a1^1, a1^2, etc. if re.search(r"^[abcxyz]{1}[0-9]*[+-]$", ion): x = re.search(r"^[abcxyz]{1}[0-9]*[+-]$", ion) elif re.search(r"^[abcxyz]{1}[0-9]*\^[0-9]*$", ion): @@ -1114,10 +1069,7 @@ def get_ion_color(ion): x = None if x: return colormap[ion[0]] - return ColorGenerator.color_blind_friendly_map[ - ColorGenerator.Colors.DARKGRAY - ] - + return ColorGenerator.color_blind_friendly_map[ColorGenerator.Colors.DARKGRAY] return [get_ion_color(ion) for ion in ion_annotations] def to_line(self, x, y): @@ -1139,31 +1091,19 @@ def convert_for_line_plots(self, data: DataFrame, x: str, y: str) -> DataFrame: def get_spectrum_tooltip_data(self, spectrum: DataFrame, x: str, y: str): """Get tooltip data for a spectrum plot.""" - - # Need to group data in correct order for tooltips if self.by is not None: grouped = spectrum.groupby(self.by, sort=False) self.data = concat([group for _, group in grouped], ignore_index=True) - - # Hover tooltips with m/z, intensity and optional information entries = {"m/z": x, "intensity": y} - for optional in ( - "native_id", - self.ion_annotation, - self.sequence_annotation, - ): + for optional in ("native_id", self.ion_annotation, self.sequence_annotation): if optional in self.data.columns: entries[optional.replace("_", " ")] = optional - # Create tooltips and custom hover data with backend specific formatting - tooltips, custom_hover_data = self._create_tooltips( - entries=entries, index=False - ) - # Repeat data each time (since each peak is represented by three points in line plot) + tooltips, custom_hover_data = self._create_tooltips(entries=entries, index=False) custom_hover_data = repeat(custom_hover_data, 3, axis=0) - return tooltips, custom_hover_data + class PeakMapPlot(BaseMSPlot, ABC): # need to inherit from ChromatogramPlot and SpectrumPlot for get_line_renderer and get_vline_renderer methods respectively @property From a33fee3626545701ccbaeecc77f65caa6fe71cc0 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:45:16 +0530 Subject: [PATCH 06/18] Simplifying MultiPlot Interface #83-resolving_conflick-2 --- pyopenms_viz/_core.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 6c88203b..d51b7582 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -541,10 +541,6 @@ def _create_tooltips(self, entries: dict, index: bool = True): pass -from math import ceil -import matplotlib.pyplot as plt -import warnings - class ChromatogramPlot(BaseMSPlot, ABC): _config: ChromatogramConfig = None From 8ffab60473b205fedc813913687d4d696320ef36 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:47:29 +0530 Subject: [PATCH 07/18] Simplifying MultiPlot Interface #83-resolving_conflick-3 --- pyopenms_viz/_core.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index d51b7582..7b40220b 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -710,16 +710,6 @@ def plot(self): self._modify_y_range((0, self.data[self.y].max()), (0, 0.1)) -import warnings -from math import ceil -from typing import List, Literal -import re -import numpy as np -from pandas import DataFrame, concat -from numpy import nan -from statistics import mean -# Import your other necessary modules (e.g., for ColorGenerator, mz_tolerance_binning, sturges_rule, freedman_diaconis_rule) - class SpectrumPlot(BaseMSPlot, ABC): @property From ae057f9a31daf4bf84046afe46cc4462573681d3 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sat, 29 Mar 2025 20:37:49 +0530 Subject: [PATCH 08/18] Simplifying MultiPlot Interface #83-resolving_conflick-4 --- ...estigate_spectrum_binning_ms_matplotlib.py | 43 ++++-- pyopenms_viz/_core.py | 124 +++++++++--------- pyopenms_viz/_matplotlib/core.py | 51 +++++++ 3 files changed, 142 insertions(+), 76 deletions(-) diff --git a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py index e754b90f..16c4a4fe 100644 --- a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py @@ -1,8 +1,8 @@ """ -Investigate Spctrum Binning ms_matplotlib +Investigate Spectrum Binning ms_matplotlib ======================================= -Here we use a dummy spectrum example to investigate spectrum binning. +Here we use a dummy spectrum example to investigate spectrum binning. """ import pandas as pd @@ -11,19 +11,30 @@ from io import StringIO import sys import os + +# Add parent directories to the path (adjust as necessary) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +# Set the plotting backend to ms_matplotlib pd.options.plotting.backend = "ms_matplotlib" -# download the file for example plotting -url = ( - "https://github.com/OpenMS/pyopenms_viz/releases/download/v0.1.5/TestSpectrumDf.tsv" -) +# Download the file for example plotting +url = "https://github.com/OpenMS/pyopenms_viz/releases/download/v0.1.5/TestSpectrumDf.tsv" response = requests.get(url) response.raise_for_status() # Check for any HTTP errors df = pd.read_csv(StringIO(response.text), sep="\t") -# Let's assess the peak binning and create a 4 by 2 subplot to visualize the different methods of binning +# Add a 'Run' column and duplicate entries for each run group. +# For example, here we create three run groups (1, 2, and 3). +runs = [1, 2, 3] +df_list = [] +for run in runs: + df_run = df.copy() + df_run["Run"] = run + df_list.append(df_run) +df = pd.concat(df_list, ignore_index=True) + +# Update the parameters for binning and visualization. params_list = [ {"title": "Spectrum (Raw)", "bin_peaks": False}, { @@ -75,18 +86,26 @@ }, ] -# Create a 3-row subplot +# Create a 4x2 subplot grid to visualize different binning methods. fig, axs = plt.subplots(4, 2, figsize=(14, 14)) i = j = 0 for params in params_list: - p = df.plot( - kind="spectrum", x="mz", y="intensity", canvas=axs[i][j], grid=False, show_plot=False, **params + # Here we pass the "Run" column to group the spectrum by run. + df.plot( + kind="spectrum", + x="mz", + y="intensity", + canvas=axs[i][j], + grid=False, + show_plot=False, + by="Run", + **params ) j += 1 - if j >= 2: # If we've filled two columns, move to the next row + if j >= 2: # Move to next row when two columns are filled. j = 0 i += 1 fig.tight_layout() -plt.show() \ No newline at end of file +plt.show() diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 7b40220b..514acd22 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -604,7 +604,15 @@ def plot(self): """ Create the plot using the validated configuration. """ - tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None + tile_by = self._config.tile_by + + # Define tooltips based on the overall data columns. + tooltip_entries = {"retention time": self.x, "intensity": self.y} + if "Annotation" in self.data.columns: + tooltip_entries["annotation"] = "Annotation" + if "product_mz" in self.data.columns: + tooltip_entries["product m/z"] = "product_mz" + tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) if tile_by: # Group the data by the tile_by column. @@ -612,55 +620,40 @@ def plot(self): num_groups = len(grouped) # Use tiling options from the configuration and set instance properties. - self.tile_columns = self._config.tile_columns # e.g. default value set in _config + self.tile_columns = self._config.tile_columns self.tile_rows = int(ceil(num_groups / self.tile_columns)) # Create a figure with a grid of subplots. - fig, axes = plt.subplots(self.tile_rows, self.tile_columns, squeeze=False) - axes = axes.flatten() # Flatten for easier indexing. + fig, axes = self._create_subplots(self.tile_rows, self.tile_columns) # Loop through each group and generate the corresponding subplot. for i, (group_val, group_df) in enumerate(grouped): ax = axes[i] + # Construct the title for this subplot. + title = f"{tile_by}: {group_val}" - # Prepare tooltips for this group (if applicable). - tooltip_entries = {"retention time": self.x, "intensity": self.y} - if "Annotation" in group_df.columns: - tooltip_entries["annotation"] = "Annotation" - if "product_mz" in group_df.columns: - tooltip_entries["product m/z"] = "product_mz" - tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) - - # Get a line renderer instance and generate the plot for the current group. - linePlot = self.get_line_renderer(data=group_df, config=self._config) - linePlot.canvas = ax + # Get a line renderer instance and generate the plot for the current group, + # passing the current axis (canvas) and title directly. + linePlot = self.get_line_renderer(data=group_df, config=self._config, canvas=ax, title=title) linePlot.generate(tooltips, custom_hover_data) - # Set the title of the subplot based on the group value. - ax.set_title(f"{tile_by}: {group_val}", fontsize=14) - # Adjust the y-axis limits for the subplot. - ax.set_ylim(0, group_df[self.y].max()) + # Use the abstracted function to modify the y-axis range. + self._modify_y_range((0, group_df[self.y].max()), (0, 0.1)) - # Add annotations if available. + # Add annotations for the current group if available. if self.annotation_data is not None and tile_by in self.annotation_data.columns: group_annotations = self.annotation_data[self.annotation_data[tile_by] == group_val] self._add_peak_boundaries(group_annotations) # Remove any extra axes if the grid size is larger than the number of groups. - for j in range(i + 1, len(axes)): - fig.delaxes(axes[j]) + self._delete_extra_axes(axes, start_index=i + 1) fig.tight_layout() self.canvas = fig else: - # Fallback: plot on a single canvas if tiling is not specified. - tooltip_entries = {"retention time": self.x, "intensity": self.y} - if "Annotation" in self.data.columns: - tooltip_entries["annotation"] = "Annotation" - if "product_mz" in self.data.columns: - tooltip_entries["product m/z"] = "product_mz" - tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) + # For the non-tiled case, create the plot for the entire dataset. linePlot = self.get_line_renderer(data=self.data, config=self._config) + # Here, spectrumPlot.generate returns an Axes, not a Figure. self.canvas = linePlot.generate(tooltips, custom_hover_data) self._modify_y_range((0, self.data[self.y].max()), (0, 0.1)) @@ -672,8 +665,13 @@ def plot(self): def _add_peak_boundaries(self, annotation_data): """ Prepare data for adding peak boundaries to the plot. - (Override this method if needed.) + This is not a complete method should be overridden by subclasses. + Args: + annotation_data (DataFrame): The feature data containing the peak boundaries. + Returns: + None """ + # compute the apex intensity self.compute_apex_intensity(annotation_data) def compute_apex_intensity(self, annotation_data): @@ -819,28 +817,24 @@ def _computed_num_bins(self): else: return self.num_x_bins + def plot(self): """Standard spectrum plot with m/z on x-axis, intensity on y-axis and optional mirror spectrum.""" - # Since tile_by was validated in __init__, we can assume that if set, the column exists. - tile_by = self._config.tile_by if hasattr(self._config, "tile_by") else None + tile_by = self._config.tile_by if tile_by: - # Group data by tile_by column + # Group data by tile_by column and assign tiling properties grouped = self.data.groupby(tile_by) num_groups = len(grouped) - - # Assign tiling properties to the instance self.tile_columns = self._config.tile_columns self.tile_rows = ceil(num_groups / self.tile_columns) - # Create a figure with a grid of subplots - fig, axes = plt.subplots(self.tile_rows, self.tile_columns, squeeze=False) - axes = axes.flatten() + # Create a figure with a grid of subplots using the backend-specific helper + fig, axes = self._create_subplots(self.tile_rows, self.tile_columns) for i, (group_val, group_df) in enumerate(grouped): - # Prepare group-specific spectrum data + # Prepare group-specific spectrum and reference data group_spectrum = self._prepare_data(group_df) - # Filter reference spectrum for current group if applicable. if self.reference_spectrum is not None and tile_by in self.reference_spectrum.columns: group_reference = self._prepare_data( self.reference_spectrum[self.reference_spectrum[tile_by] == group_val] @@ -861,56 +855,61 @@ def plot(self): elif self.ion_annotation is not None and self.ion_annotation in group_df.columns: self.by = self.ion_annotation - # Get annotations for this group and convert data for line plot + # Get annotations and convert data for line plots ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_spectrum, self.x, self.y) group_spectrum = self.convert_for_line_plots(group_spectrum, self.x, self.y) self.color = self._get_colors(group_spectrum, kind="peak") - spectrumPlot = self.get_line_renderer(data=group_spectrum, by=self.by, color=self.color, config=self._config) + + # Pass title directly into the renderer for backend abstraction + title = f"{tile_by}: {group_val}" + spectrumPlot = self.get_line_renderer( + data=group_spectrum, by=self.by, color=self.color, config=self._config, title=title + ) spectrumPlot.canvas = axes[i] spectrumPlot.generate(tooltips, custom_hover_data) spectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) - axes[i].set_title(f"{tile_by}: {group_val}", fontsize=14) - + # Handle mirror spectrum if applicable if self.mirror_spectrum and group_reference is not None: group_reference[self.y] = group_reference[self.y] * -1 color_mirror = self._get_colors(group_reference, kind="peak") group_reference = self.convert_for_line_plots(group_reference, self.x, self.y) _, reference_custom_hover_data = self.get_spectrum_tooltip_data(group_reference, self.x, self.y) - mirrorSpectrumPlot = self.get_line_renderer(data=group_reference, color=color_mirror, config=self._config) + mirrorSpectrumPlot = self.get_line_renderer( + data=group_reference, color=color_mirror, config=self._config + ) mirrorSpectrumPlot.canvas = axes[i] mirrorSpectrumPlot.generate(None, None) ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(group_reference, self.x, self.y) mirrorSpectrumPlot._add_annotations(axes[i], ann_texts, ann_xs, ann_ys, ann_colors) - - # Remove any extra axes if present and finalize layout - for j in range(i + 1, len(axes)): - fig.delaxes(axes[j]) + + # Delete extra axes if present and finalize layout + self._delete_extra_axes(axes, start_index=i + 1) fig.tight_layout() self.canvas = fig + else: - # Fallback: single plot behavior if tiling is not requested + # Single-plot behavior when tiling is not requested spectrum = self._prepare_data(self.data) - if self.reference_spectrum is not None: - reference_spectrum = self._prepare_data(self.reference_spectrum) - else: - reference_spectrum = None + reference_spectrum = self._prepare_data(self.reference_spectrum) if self.reference_spectrum is not None else None entries = {"m/z": self.x, "intensity": self.y} for optional in ("native_id", self.ion_annotation, self.sequence_annotation): if optional in self.data.columns: entries[optional.replace("_", " ")] = optional tooltips, custom_hover_data = self._create_tooltips(entries=entries, index=False) - + if self.peak_color is not None and self.peak_color in self.data.columns: self.by = self.peak_color elif self.ion_annotation is not None and self.ion_annotation in self.data.columns: self.by = self.ion_annotation + ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(spectrum, self.x, self.y) spectrum = self.convert_for_line_plots(spectrum, self.x, self.y) self.color = self._get_colors(spectrum, kind="peak") spectrumPlot = self.get_line_renderer(data=spectrum, by=self.by, color=self.color, config=self._config) self.canvas = spectrumPlot.generate(tooltips, custom_hover_data) spectrumPlot._add_annotations(self.canvas, ann_texts, ann_xs, ann_ys, ann_colors) + if self.mirror_spectrum and self.reference_spectrum is not None: reference_spectrum[self.y] = reference_spectrum[self.y] * -1 color_mirror = self._get_colors(reference_spectrum, kind="peak") @@ -920,6 +919,7 @@ def plot(self): mirrorSpectrumPlot.generate(None, None) ann_texts, ann_xs, ann_ys, ann_colors = self._get_annotations(reference_spectrum, self.x, self.y) mirrorSpectrumPlot._add_annotations(self.canvas, ann_texts, ann_xs, ann_ys, ann_colors) + self.plot_x_axis_line(self.canvas, line_width=2) min_values = [spectrum[self.x].min()] max_values = [spectrum[self.x].max()] @@ -927,15 +927,11 @@ def plot(self): min_values.append(reference_spectrum[self.x].min()) max_values.append(reference_spectrum[self.x].max()) self._modify_x_range((min(min_values), max(max_values)), padding=(0.20, 0.20)) - max_value = spectrum[self.y].max() - min_value = 0 - min_padding = 0 - max_padding = 0.15 - if reference_spectrum is not None and self.mirror_spectrum: - min_value = reference_spectrum[self.y].min() - min_padding = -0.2 - max_padding = 0.4 - self._modify_y_range((min_value, max_value), padding=(min_padding, max_padding)) + + # Use the helper to compute y-range and padding, then apply + y_range, y_padding = self._compute_y_range_and_padding(spectrum, reference_spectrum) + self._modify_y_range(y_range, padding=y_padding) + def _bin_peaks(self, df: DataFrame) -> DataFrame: """ diff --git a/pyopenms_viz/_matplotlib/core.py b/pyopenms_viz/_matplotlib/core.py index 11e7c737..ac01b460 100644 --- a/pyopenms_viz/_matplotlib/core.py +++ b/pyopenms_viz/_matplotlib/core.py @@ -35,6 +35,57 @@ class MATPLOTLIBPlot(BasePlot, ABC): data (DataFrame): The input data frame. """ + def _compute_y_range_and_padding(self, spectrum, reference_spectrum=None): + """ + Compute the y-axis range and padding based on the spectrum data. + + Args: + spectrum (DataFrame): The primary spectrum data. + reference_spectrum (DataFrame, optional): The reference spectrum data. + + Returns: + Tuple[Tuple[float, float], Tuple[float, float]]: The y-axis range and padding. + """ + max_value = spectrum[self.y].max() + if reference_spectrum is not None and self.mirror_spectrum: + min_value = reference_spectrum[self.y].min() + padding = (-0.2, 0.4) + else: + min_value = 0 + padding = (0, 0.15) + return (min_value, max_value), padding + + + + def _create_subplots(self, rows: int, columns: int): + """ + Create a grid of subplots using matplotlib. + + Args: + rows (int): Number of subplot rows. + columns (int): Number of subplot columns. + figsize (Tuple[float, float]): Size of the figure in inches. + + Returns: + Tuple[Figure, List[Axes]]: The matplotlib Figure and a flattened list of Axes. + """ + fig, axes = plt.subplots(rows, columns, squeeze=False) + axes = axes.flatten() + return fig, axes + + + def _delete_extra_axes(self, axes, start_index: int): + """ + Remove any extra axes from a grid if the number of groups is smaller than + the number of subplot axes. + + Args: + axes (list): List of axes objects. + start_index (int): The index in the axes list from which to start deleting. + """ + for j in range(start_index, len(axes)): + self.canvas.delaxes(axes[j]) + # In matplotlib the canvas is referred to as a Axes, the figure object is the encompassing object @property def ax(self): From 3cc396398b62cd8cba012ed282ccc72a254cbccc Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 08:30:14 +0530 Subject: [PATCH 09/18] Simplifying MultiPlot Interface #83-resolving_conflick-5 --- pyopenms_viz/_core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 514acd22..7c0ed236 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -14,8 +14,6 @@ from pandas.util._decorators import Appender import re -import matplotlib.pyplot as plt -from math import ceil from numpy import ceil, log1p, log2, nan, mean, repeat, concatenate from ._config import ( From 29dbd1eafd1b62ae9749339106f5a6944dc26c67 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 08:46:45 +0530 Subject: [PATCH 10/18] Simplifying MultiPlot Interface #83-resolving_conflick-6 --- .../plot_spyogenes_subplots_ms_matplotlib.py | 6 +- pyopenms_viz/_config.py | 4 +- pyopenms_viz/_core.py | 72 +++++++++---------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index 01f47d04..eb01e22f 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -50,13 +50,13 @@ ##### Plotting Using Tile By ##### # Instead of pre-creating subplots and looping over RUN_NAMES, -# we call the plot method once and provide a tile_by parameter. +# we call the plot method once and provide a facet_column parameter. fig = chrom_df.plot( kind="chromatogram", x="rt", y="int", - tile_by="run_name", # Automatically groups data by run_name and creates subplots - tile_columns=1, # Layout: 1 column (one subplot per row) + facet_column="run_name", # Automatically groups data by run_name and creates subplots + facet_col_wrap=1, # Layout: 1 column (one subplot per row) grid=False, by="ion_annotation", title_font_size=16, diff --git a/pyopenms_viz/_config.py b/pyopenms_viz/_config.py index d2afca6a..341adc07 100644 --- a/pyopenms_viz/_config.py +++ b/pyopenms_viz/_config.py @@ -205,8 +205,8 @@ def default_legend_factory(): legend_config: LegendConfig | dict = field(default_factory=default_legend_factory) opacity: float = 1.0 - tile_by: str | None = None # Name of the column to tile the plot by. - tile_columns: int = 1 # How many columns in the subplot grid. + facet_column: str | None = None # Name of the column to tile the plot by. + facet_col_wrap: int = 1 # How many columns in the subplot grid. def __post_init__(self): # if legend_config is a dictionary, update it to LegendConfig object diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 7c0ed236..7f33b7b5 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -583,26 +583,26 @@ def _validate_plot_config(self): """ Validate plot configuration options (e.g., tiling parameters) before plotting. """ - # Validate the tile_by option: check if the specified column exists in the data. - if hasattr(self._config, "tile_by"): - tile_by = self._config.tile_by - if tile_by not in self.data.columns: + # Validate the facet_column option: check if the specified column exists in the data. + if hasattr(self._config, "facet_column"): + facet_column = self._config.facet_column + if facet_column not in self.data.columns: warnings.warn( - f"tile_by column '{tile_by}' not found in data. Plot will be generated without tiling." + f"facet_column column '{facet_column}' not found in data. Plot will be generated without tiling." ) - self._config.tile_by = None + self._config.facet_column = None - # Validate tile_columns: ensure it is a positive integer. - if hasattr(self._config, "tile_columns"): - if not isinstance(self._config.tile_columns, int) or self._config.tile_columns < 1: - warnings.warn("tile_columns must be a positive integer. Defaulting to 1.") - self._config.tile_columns = 1 + # Validate facet_col_wrap: ensure it is a positive integer. + if hasattr(self._config, "facet_col_wrap"): + if not isinstance(self._config.facet_col_wrap, int) or self._config.facet_col_wrap < 1: + warnings.warn("facet_col_wrap must be a positive integer. Defaulting to 1.") + self._config.facet_col_wrap = 1 def plot(self): """ Create the plot using the validated configuration. """ - tile_by = self._config.tile_by + facet_column = self._config.facet_column # Define tooltips based on the overall data columns. tooltip_entries = {"retention time": self.x, "intensity": self.y} @@ -612,23 +612,23 @@ def plot(self): tooltip_entries["product m/z"] = "product_mz" tooltips, custom_hover_data = self._create_tooltips(tooltip_entries, index=False) - if tile_by: - # Group the data by the tile_by column. - grouped = self.data.groupby(tile_by) + if facet_column: + # Group the data by the facet_column column. + grouped = self.data.groupby(facet_column) num_groups = len(grouped) # Use tiling options from the configuration and set instance properties. - self.tile_columns = self._config.tile_columns - self.tile_rows = int(ceil(num_groups / self.tile_columns)) + self.facet_col_wrap = self._config.facet_col_wrap + self.tile_rows = int(ceil(num_groups / self.facet_col_wrap)) # Create a figure with a grid of subplots. - fig, axes = self._create_subplots(self.tile_rows, self.tile_columns) + fig, axes = self._create_subplots(self.tile_rows, self.facet_col_wrap) # Loop through each group and generate the corresponding subplot. for i, (group_val, group_df) in enumerate(grouped): ax = axes[i] # Construct the title for this subplot. - title = f"{tile_by}: {group_val}" + title = f"{facet_column}: {group_val}" # Get a line renderer instance and generate the plot for the current group, # passing the current axis (canvas) and title directly. @@ -639,8 +639,8 @@ def plot(self): self._modify_y_range((0, group_df[self.y].max()), (0, 0.1)) # Add annotations for the current group if available. - if self.annotation_data is not None and tile_by in self.annotation_data.columns: - group_annotations = self.annotation_data[self.annotation_data[tile_by] == group_val] + if self.annotation_data is not None and facet_column in self.annotation_data.columns: + group_annotations = self.annotation_data[self.annotation_data[facet_column] == group_val] self._add_peak_boundaries(group_annotations) # Remove any extra axes if the grid size is larger than the number of groups. @@ -747,13 +747,13 @@ def __init__(self, data, **kwargs) -> None: self.data[self.y] = self.data[self.y] / self.data[self.y].max() * 100 # Validate tiling configuration in the constructor - if hasattr(self._config, "tile_by"): - tile_by = self._config.tile_by - if tile_by not in self.data.columns: + if hasattr(self._config, "facet_column"): + facet_column = self._config.facet_column + if facet_column not in self.data.columns: warnings.warn( - f"tile_by column '{tile_by}' not found in data. Plot will be generated without tiling." + f"facet_column column '{facet_column}' not found in data. Plot will be generated without tiling." ) - self._config.tile_by = None + self._config.facet_column = None # (Other configuration validations can be added here as needed.) # Proceed to generate the plot @@ -819,23 +819,23 @@ def _computed_num_bins(self): def plot(self): """Standard spectrum plot with m/z on x-axis, intensity on y-axis and optional mirror spectrum.""" - tile_by = self._config.tile_by - if tile_by: - # Group data by tile_by column and assign tiling properties - grouped = self.data.groupby(tile_by) + facet_column = self._config.facet_column + if facet_column: + # Group data by facet_column column and assign tiling properties + grouped = self.data.groupby(facet_column) num_groups = len(grouped) - self.tile_columns = self._config.tile_columns - self.tile_rows = ceil(num_groups / self.tile_columns) + self.facet_col_wrap = self._config.facet_col_wrap + self.tile_rows = ceil(num_groups / self.facet_col_wrap) # Create a figure with a grid of subplots using the backend-specific helper - fig, axes = self._create_subplots(self.tile_rows, self.tile_columns) + fig, axes = self._create_subplots(self.tile_rows, self.facet_col_wrap) for i, (group_val, group_df) in enumerate(grouped): # Prepare group-specific spectrum and reference data group_spectrum = self._prepare_data(group_df) - if self.reference_spectrum is not None and tile_by in self.reference_spectrum.columns: + if self.reference_spectrum is not None and facet_column in self.reference_spectrum.columns: group_reference = self._prepare_data( - self.reference_spectrum[self.reference_spectrum[tile_by] == group_val] + self.reference_spectrum[self.reference_spectrum[facet_column] == group_val] ) else: group_reference = None @@ -859,7 +859,7 @@ def plot(self): self.color = self._get_colors(group_spectrum, kind="peak") # Pass title directly into the renderer for backend abstraction - title = f"{tile_by}: {group_val}" + title = f"{facet_column}: {group_val}" spectrumPlot = self.get_line_renderer( data=group_spectrum, by=self.by, color=self.color, config=self._config, title=title ) From 93af84582a669e44d5e4abae2bed271d5d7e21fc Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 08:51:28 +0530 Subject: [PATCH 11/18] Simplifying MultiPlot Interface #83-resolving_conflick-7 --- .../plot_spyogenes_subplots_ms_matplotlib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index eb01e22f..2759e74a 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -10,7 +10,6 @@ import zipfile import matplotlib.pyplot as plt import sys -import sys import os sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) From c2aa90c5022b3d3ce2400d50ab272a51558baa3c Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:06:30 +0530 Subject: [PATCH 12/18] Simplifying MultiPlot Interface #83-resolving_conflick-8 --- pyopenms_viz/_matplotlib/core.py | 4 +++- test/conftest.py | 4 ++++ test/test_chromatogram.py | 3 +++ test/test_spectrum.py | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pyopenms_viz/_matplotlib/core.py b/pyopenms_viz/_matplotlib/core.py index ac01b460..06c70bc3 100644 --- a/pyopenms_viz/_matplotlib/core.py +++ b/pyopenms_viz/_matplotlib/core.py @@ -4,6 +4,8 @@ from typing import Tuple import re from numpy import nan +import matplotlib +matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib.lines import Line2D from matplotlib.patches import Rectangle @@ -52,7 +54,7 @@ def _compute_y_range_and_padding(self, spectrum, reference_spectrum=None): padding = (-0.2, 0.4) else: min_value = 0 - padding = (0, 0.15) + padding = (0, 0.15) # Ensure this is a tuple return (min_value, max_value), padding diff --git a/test/conftest.py b/test/conftest.py index 92123f0e..95b006e0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,9 @@ import pytest import pandas as pd +import os +import sys +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + from pathlib import Path from pyopenms_viz.testing import ( MatplotlibSnapshotExtension, diff --git a/test/test_chromatogram.py b/test/test_chromatogram.py index a38124ba..ee938d9f 100644 --- a/test/test_chromatogram.py +++ b/test/test_chromatogram.py @@ -5,6 +5,9 @@ import pytest import pandas as pd +import os +import sys +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) @pytest.mark.parametrize( diff --git a/test/test_spectrum.py b/test/test_spectrum.py index 27d398bf..b23874e7 100644 --- a/test/test_spectrum.py +++ b/test/test_spectrum.py @@ -5,6 +5,10 @@ import pytest import pandas as pd +import os +import sys +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + @pytest.mark.parametrize( From f35157d5da6140d29d59df2cadbfafda64ae6631 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:07:39 +0530 Subject: [PATCH 13/18] Simplifying MultiPlot Interface #83-resolving_conflick-9 --- test/test_chromatogram.py | 3 --- test/test_spectrum.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/test/test_chromatogram.py b/test/test_chromatogram.py index ee938d9f..a38124ba 100644 --- a/test/test_chromatogram.py +++ b/test/test_chromatogram.py @@ -5,9 +5,6 @@ import pytest import pandas as pd -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) @pytest.mark.parametrize( diff --git a/test/test_spectrum.py b/test/test_spectrum.py index b23874e7..b49f9344 100644 --- a/test/test_spectrum.py +++ b/test/test_spectrum.py @@ -5,9 +5,6 @@ import pytest import pandas as pd -import os -import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) From 82dfd03c3750032874805475abdec2c00330e749 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:15:35 +0530 Subject: [PATCH 14/18] Simplifying MultiPlot Interface #83-resolving_conflick-10 --- pyopenms_viz/_matplotlib/core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyopenms_viz/_matplotlib/core.py b/pyopenms_viz/_matplotlib/core.py index 06c70bc3..d2f9bdcd 100644 --- a/pyopenms_viz/_matplotlib/core.py +++ b/pyopenms_viz/_matplotlib/core.py @@ -86,7 +86,8 @@ def _delete_extra_axes(self, axes, start_index: int): start_index (int): The index in the axes list from which to start deleting. """ for j in range(start_index, len(axes)): - self.canvas.delaxes(axes[j]) + self.fig.delaxes(axes[j]) + # In matplotlib the canvas is referred to as a Axes, the figure object is the encompassing object @property From 76d56a32668f4a32ee7fa3ba19c92aec6cba8ffa Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:30:32 +0530 Subject: [PATCH 15/18] Simplifying MultiPlot Interface #83-resolving_conflick-11 --- .../plot_investigate_spectrum_binning_ms_matplotlib.py | 4 ++-- .../plot_spyogenes_subplots_ms_matplotlib.py | 2 +- test/conftest.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py index 16c4a4fe..1aac6254 100644 --- a/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_investigate_spectrum_binning_ms_matplotlib.py @@ -12,8 +12,8 @@ import sys import os -# Add parent directories to the path (adjust as necessary) -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +# # Add parent directories to the path (adjust as necessary) +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) # Set the plotting backend to ms_matplotlib pd.options.plotting.backend = "ms_matplotlib" diff --git a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py index 2759e74a..f183960c 100644 --- a/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py +++ b/docs/gallery_scripts_template/plot_spyogenes_subplots_ms_matplotlib.py @@ -11,7 +11,7 @@ import matplotlib.pyplot as plt import sys import os -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) # Set the plotting backend diff --git a/test/conftest.py b/test/conftest.py index 95b006e0..0ce02391 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -2,7 +2,7 @@ import pandas as pd import os import sys -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +# sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from pathlib import Path from pyopenms_viz.testing import ( From ad2104afe74f5b24c87f499645fb261540d91fcf Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:43:25 +0530 Subject: [PATCH 16/18] Simplifying MultiPlot Interface #83-resolving_conflick-12 --- pyopenms_viz/_bokeh/core.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyopenms_viz/_bokeh/core.py b/pyopenms_viz/_bokeh/core.py index 0450d71f..593d66a6 100644 --- a/pyopenms_viz/_bokeh/core.py +++ b/pyopenms_viz/_bokeh/core.py @@ -544,7 +544,22 @@ class BOKEHSpectrumPlot(BOKEH_MSPlot, SpectrumPlot): """ Class for assembling a Bokeh spectrum plot """ + def _compute_y_range_and_padding(self, spectrum, reference_spectrum=None) -> tuple[tuple[float, float], float]: + # Extract y-values from the spectrum data. + y_values = [val for val in spectrum if val is not None] + if not y_values: + return (0, 0), 0 + y_min = min(y_values) + y_max = max(y_values) + # Example padding of 10% of the range + padding = (y_max - y_min) * 0.1 + return (y_min - padding, y_max + padding), padding + def plot(self): + # Now you can use the computed y_range + y_range, y_padding = self._compute_y_range_and_padding(self.data[self.y]) + self._modify_y_range(y_range) + # Continue with the rest of your plotting logic... pass From f37941fdc9c56b20b14ef7f57c4b6439cee61cf3 Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Sun, 30 Mar 2025 10:48:40 +0530 Subject: [PATCH 17/18] Simplifying MultiPlot Interface #83-resolving_conflick-13 --- pyopenms_viz/_bokeh/core.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/pyopenms_viz/_bokeh/core.py b/pyopenms_viz/_bokeh/core.py index 593d66a6..0450d71f 100644 --- a/pyopenms_viz/_bokeh/core.py +++ b/pyopenms_viz/_bokeh/core.py @@ -544,22 +544,7 @@ class BOKEHSpectrumPlot(BOKEH_MSPlot, SpectrumPlot): """ Class for assembling a Bokeh spectrum plot """ - def _compute_y_range_and_padding(self, spectrum, reference_spectrum=None) -> tuple[tuple[float, float], float]: - # Extract y-values from the spectrum data. - y_values = [val for val in spectrum if val is not None] - if not y_values: - return (0, 0), 0 - y_min = min(y_values) - y_max = max(y_values) - # Example padding of 10% of the range - padding = (y_max - y_min) * 0.1 - return (y_min - padding, y_max + padding), padding - def plot(self): - # Now you can use the computed y_range - y_range, y_padding = self._compute_y_range_and_padding(self.data[self.y]) - self._modify_y_range(y_range) - # Continue with the rest of your plotting logic... pass From 6785a3245963beb8fd0a861ee154224e3a14a5dc Mon Sep 17 00:00:00 2001 From: Nishant Garg <156602691+Nishantrde@users.noreply.github.com> Date: Tue, 1 Apr 2025 12:34:04 +0530 Subject: [PATCH 18/18] Simplifying MultiPlot Interface #83-resolving_conflick-14 --- pyopenms_viz/_core.py | 4 ++-- pyopenms_viz/_matplotlib/core.py | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/pyopenms_viz/_core.py b/pyopenms_viz/_core.py index 7f33b7b5..962b5f4e 100644 --- a/pyopenms_viz/_core.py +++ b/pyopenms_viz/_core.py @@ -646,7 +646,7 @@ def plot(self): # Remove any extra axes if the grid size is larger than the number of groups. self._delete_extra_axes(axes, start_index=i + 1) - fig.tight_layout() + # fig.tight_layout() self.canvas = fig else: # For the non-tiled case, create the plot for the entire dataset. @@ -883,7 +883,7 @@ def plot(self): # Delete extra axes if present and finalize layout self._delete_extra_axes(axes, start_index=i + 1) - fig.tight_layout() + # fig.tight_layout() self.canvas = fig else: diff --git a/pyopenms_viz/_matplotlib/core.py b/pyopenms_viz/_matplotlib/core.py index d2f9bdcd..6c963d9b 100644 --- a/pyopenms_viz/_matplotlib/core.py +++ b/pyopenms_viz/_matplotlib/core.py @@ -38,16 +38,7 @@ class MATPLOTLIBPlot(BasePlot, ABC): """ def _compute_y_range_and_padding(self, spectrum, reference_spectrum=None): - """ - Compute the y-axis range and padding based on the spectrum data. - Args: - spectrum (DataFrame): The primary spectrum data. - reference_spectrum (DataFrame, optional): The reference spectrum data. - - Returns: - Tuple[Tuple[float, float], Tuple[float, float]]: The y-axis range and padding. - """ max_value = spectrum[self.y].max() if reference_spectrum is not None and self.mirror_spectrum: min_value = reference_spectrum[self.y].min()