Source code for pyccapt.calibration.core.mc_plot

import math
import re

import matplotlib.pyplot as plt
import numpy as np
from adjustText import adjust_text

from pyccapt.calibration.path_utils import save_figure
from pyccapt.calibration.core.mc_plot_background_helpers import (
    calculate_noise as _calculate_noise,
    exponential_decay_with_linear_and_dc as _exp_decay,
    manual_background_fit as _manual_background_fit,
    plot_background as _plot_background,
)
from pyccapt.calibration.core.mc_plot_peak_helpers import (
    apply_hist_info_legend as _apply_hist_info_legend,
    calculate_mrp as _calculate_mrp,
    draw_rectangle as _draw_rectangle,
    find_peaks_and_widths as _find_peaks_and_widths,
)
from pyccapt.calibration.core.mc_plot_selector_helpers import (
    attach_selector as _attach_selector,
    zoom_to_x_range as _zoom_to_x_range,
)

def _normalize_range_colors(values):
    """Normalize stored range colors for matplotlib usage."""
    normalized = []
    for value in values:
        value = str(value).strip()
        if value and not value.startswith('#') and re.fullmatch(r'[A-Fa-f0-9]{6}', value):
            value = f'#{value}'
        normalized.append(value)
    return normalized

def _plain_range_label(value):
    """Convert stored ion/range labels into plain text safe for matplotlib."""
    text = str(value).strip()
    if not text:
        return text
    text = text.replace("$", "")
    text = re.sub(r"_\{([^}]*)\}", r"\1", text)
    text = re.sub(r"\^\{([^}]*)\}", r" \1", text)
    text = text.replace("{", "").replace("}", "")
    text = text.replace("^", "").strip()
    return text

def _resolve_range_display_labels(range_data):
    """Return plain-text labels for ranged overlays and legends."""
    for column in ("name", "ion_name", "ion"):
        if column in range_data.columns:
            labels = [_plain_range_label(value) for value in range_data[column].tolist()]
            if any(label for label in labels):
                return labels
    return [_plain_range_label(value) for value in range(len(range_data))]

def _resolve_range_peak_labels(range_data):
    """Return peak annotation labels, preferring the raw ion column when available."""
    if "ion" in range_data.columns:
        labels = [str(value).strip() for value in range_data["ion"].tolist()]
        if any(label for label in labels):
            return labels
    for column in ("ion_name", "name"):
        if column in range_data.columns:
            labels = [str(value).strip() for value in range_data[column].tolist()]
            if any(label for label in labels):
                return labels
    return [str(value) for value in range(len(range_data))]

[docs] class AptHistPlotter: """ This class plots the histogram of the mass-to-charge ratio (mc) or time of flight (tof) data. """ def __init__(self, mc_tof, variables=None): """ Initializes all the attributes of AptHistPlotter. Args: mc_tof (numpy.ndarray): Array for mc or tof data. variables (share_variables.Variables): The global experiment variables. """ self.line_manager = None self.distance = None self.prominence = None self.percent = None self.rectangle = None self.bins = None self.plotted_circles = [] self.plotted_lines = [] self.plotted_labels = [] self.original_x_limits = None self.bin_width = None self.fig = None self.ax = None self.mc_tof = mc_tof self.variables = variables self.x = None self.x_centers = None self.y = None self.peak_annotates = [] self.annotates = [] self.patches = None self.peaks = None self.properties = None self.peak_widths = None self.prominences = None self.mask_f = None self.plot_show = True self.legend_colors = []
[docs] def plot_histogram( self, bin_width=0.1, normalize=False, label='mc', log=True, grid=False, steps='stepfilled', fig_size=(9, 5), plot_show=True, fast=False, ): """ Plot the histogram of the mc or tof data. Args: bin_width (float): The width of the bins. normalize (bool): Whether to normalize the histogram. label (str): The label of the x-axis ('mc' or 'tof'). log (bool): Whether to use log scale for the y-axis. grid (bool): Whether to show the grid. steps (str): The type of the histogram ('stepfilled' or 'bar'). fig_size (tuple): The size of the figure. plot_show (bool): Whether to show the plot. fast (bool): Use np.histogram + fill_between instead of ax.hist for speed. Returns: tuple: A tuple of the y and x values of the histogram. """ # Define the bins self.bin_width = bin_width self.plot_show = plot_show self.bins = np.linspace(np.min(self.mc_tof), np.max(self.mc_tof), round(np.max(self.mc_tof) / bin_width)) # Plot the histogram directly self.fig, self.ax = plt.subplots(figsize=fig_size) # Force fast mode for bar-incompatible rendering or large datasets if fast and steps != 'bar': self.y, self.x = np.histogram(self.mc_tof, bins=self.bins, density=normalize) self.x_centers = (self.x[:-1] + self.x[1:]) * 0.5 self.ax.fill_between(self.x_centers, self.y, step='mid', alpha=0.9, color='slategray') self.ax.step(self.x_centers, self.y, where='mid', color='k', linewidth=0.5) self.patches = [] else: if steps == 'bar': edgecolor = None alpha = 1 else: edgecolor = 'k' alpha = 0.9 if normalize: self.y, self.x, self.patches = self.ax.hist( self.mc_tof, bins=self.bins, alpha=alpha, color='slategray', edgecolor=edgecolor, histtype=steps, density=True, ) else: self.y, self.x, self.patches = self.ax.hist( self.mc_tof, bins=self.bins, alpha=alpha, color='slategray', edgecolor=edgecolor, histtype=steps ) self.x_centers = (self.x[:-1] + self.x[1:]) * 0.5 self.ax.set_xlabel('Mass/Charge [Da]' if label == 'mc' else 'Time of Flight [ns]') self.ax.set_ylabel('Event Counts') self.ax.set_yscale('log' if log else 'linear') if grid: plt.grid(True, which='both', axis='both', linestyle='--', linewidth=0.4, alpha=0.3) if self.original_x_limits is None: self.original_x_limits = self.ax.get_xlim() # Store the original x-axis limits plt.tight_layout() if plot_show: plt.show() else: plt.close() if self.variables is not None: self.variables.x_hist = self.x self.variables.y_hist = self.y return self.y, self.x
[docs] def plot_line_hist(self): """ Plot the histogram as a line plot. Args: None Returns: None """ bin_centers = (self.bins[:-1] + self.bins[1:]) / 2 # Compute bin centers self.ax.plot(bin_centers, self.y, color='slategray') # Step 2: Remove the histogram patches (bars) for patch in self.patches: patch.set_visible(False)
[docs] def plot_range(self, range_data, legend=True, legend_loc='upper right'): """ Plot the range of the histogram. Args: range_data (data frame): The range data. legend (bool): Whether to show the legend. legend_loc (str): The location of the legend. Returns: None """ if len(self.patches) == len(self.x) - 1: colors = _normalize_range_colors(range_data['color'].tolist()) mc_low = range_data['mc_low'].tolist() mc_up = range_data['mc_up'].tolist() mc = range_data['mc'].tolist() labels = _resolve_range_display_labels(range_data) peak_labels = _resolve_range_peak_labels(range_data) color_mask = np.full((len(self.x)), '#708090') # default color is slategray for i in range(len(labels)): mask = np.logical_and(self.x >= mc_low[i], self.x <= mc_up[i]) color_mask[mask] = colors[i] for i in range(len(self.x) - 1): if color_mask[i] != '#708090': self.patches[i].set_facecolor(color_mask[i]) seen_legend_labels = set() for i in range(len(labels)): if labels[i] not in seen_legend_labels: self.legend_colors.append((labels[i], plt.Rectangle((0, 0), 1, 1, fc=colors[i]))) seen_legend_labels.add(labels[i]) x_offset = 0.0 # Adjust this value as needed # Find the bin that contains the mc[i] bin_index = np.searchsorted(self.x, mc[i]) - 1 if 0 <= bin_index < len(self.y): # Define a small range around the bin to search for the local maximum search_range = slice(max(0, bin_index - 1), min(len(self.y), bin_index + 2)) local_bins = self.y[search_range] local_x = self.x[search_range.start : search_range.stop] # Find the local maximum and its position max_idx = np.argmax(local_bins) peak_height = local_bins[max_idx] peak_position = local_x[max_idx] # Dynamic y_offset based on log scale y_offset = peak_height * 0.05 if self.ax.get_yscale() == 'log': y_offset = 10 ** (np.log10(peak_height) + 0.1) - peak_height self.peak_annotates.append( plt.text( peak_position + x_offset, peak_height + y_offset, peak_labels[i], color='black', size=10, alpha=1, rotation=90, ) ) self.annotates.append(str(i + 1)) if legend: self.plot_color_legend(loc=legend_loc) else: print('plot_range only works in plot_histogram mode=bar')
[docs] def change_peak_color(self, peak_loc, dx, color='red'): """ Change the color of the peak. Args: peak_loc (float): The location of the peak. dx (float): The width of the peak. color (str): The color of the peak. Returns: None """ bin_index = np.digitize([peak_loc], self.x) - 1 try: self.ranged_line.remove() except AttributeError: pass # Ensure bin_index is within valid range if bin_index < 0 or bin_index >= len(self.y): raise IndexError(f"Bin index {bin_index} out of range for y array of length {len(self.y)}") # Get the scalar value for ymax ymax = float(self.y[bin_index]) # Plot the vertical line on the plotter's own axes so the marker lands # on the currently displayed figure even when matplotlib's pyplot state # has drifted to a different figure (common under %matplotlib ipympl). self.ranged_line = self.ax.axvline( x=peak_loc, color=color, linestyle='dashdot', linewidth=2, ymax=ymax, ) if self.fig is not None and self.fig.canvas is not None: self.fig.canvas.draw_idle()
[docs] def plot_peaks(self, range_data=None, mode='peaks'): """ Plot the peaks of the histogram. Args: range_data (data frame): The range data. mode (str): The mode of the peaks ('peaks', 'range', or 'peaks_range'). Returns: None """ x_offset = 0.0 # Adjust this value as needed if range_data is not None: labels = _resolve_range_peak_labels(range_data) mc = range_data['mc'].tolist() for i in range(len(labels)): if self.y is None or len(self.y) == 0 or self.x is None or len(self.x) == 0: continue # Find the bin that contains the mc[i] bin_index = np.searchsorted(self.x, mc[i]) - 1 clamped_index = min(max(int(bin_index), 0), len(self.y) - 1) if 0 <= bin_index < len(self.y): # Define a small range around the bin to search for the local maximum search_range = slice(max(0, bin_index - 1), min(len(self.y), bin_index + 2)) local_bins = self.y[search_range] local_x = self.x[search_range.start : search_range.stop] # Find the local maximum and its position max_idx = np.argmax(local_bins) peak_height = local_bins[max_idx] peak_position = local_x[max_idx] # Dynamic y_offset based on log scale y_offset = peak_height * 0.05 if self.ax.get_yscale() == 'log': y_offset = 10 ** (np.log10(peak_height) + 0.1) - peak_height else: peak_position = float(np.clip(mc[i], self.x[0], self.x[-1])) peak_height = float(self.y[clamped_index]) y_offset = peak_height * 0.05 if self.ax.get_yscale() == 'log' and peak_height > 0: y_offset = 10 ** (np.log10(peak_height) + 0.1) - peak_height if self.plot_show: self.peak_annotates.append( plt.text( peak_position + x_offset, peak_height + y_offset, labels[i], color='black', size=10, alpha=1, rotation=90, ) ) self.annotates.append(str(i + 1)) else: y_offset = 0.0 # Adjust this value as needed if mode == 'peaks': for i in range(len(self.peaks)): if self.plot_show: # Dynamic y_offset based on log scale peak_height = self.y[self.peaks][i] y_offset = peak_height * 0.05 if self.ax.get_yscale() == 'log': y_offset = 10 ** (np.log10(peak_height) + 0.1) - peak_height self.peak_annotates.append( plt.text( self.x[self.peaks][i] + x_offset, peak_height + y_offset, '%s' % '{:.2f}'.format(self.x[self.peaks][i]), color='black', size=10, alpha=1, rotation=90, ) ) self.annotates.append(str(i + 1)) elif mode == 'range': y_offset = 0.0 # Adjust this value as needed for i in range(len(self.variables.peaks_x_selected)): # Find the bin that contains the mc[i] bin_index = np.searchsorted(self.x, self.variables.peaks_x_selected[i]) peak_height = self.y[bin_index] * ( (self.variables.peaks_x_selected[i] - self.x[bin_index - 1]) / self.bin_width ) if self.plot_show: self.peak_annotates.append( plt.text( self.variables.peaks_x_selected[i] + x_offset, peak_height + y_offset, '%s' % '{:.2f}'.format(self.variables.peaks_x_selected[i]), color='black', size=10, alpha=1, rotation=90, ) ) self.annotates.append(str(i + 1))
[docs] def plot_color_legend(self, loc, detailed_isotope=False, detailed_charge=False): """ Plot the color legend. Args: loc (str): The location of the legend. Returns: None """ # make a copy of the legend colors legend_colors_edited = self.legend_colors.copy() if not detailed_isotope or not detailed_charge: # Regular expression pattern to remove isotope notation pattern = r"\$\{\}\^\{\d+\}([A-Za-z]+.*)\$" for i in range(len(legend_colors_edited)): legend_colors_edited[i] = (re.sub(pattern, r"$\1$", legend_colors_edited[i][0]), legend_colors_edited[i][1]) # remove ununique labels unique_tuples = {} for key, value in legend_colors_edited: if key not in unique_tuples: unique_tuples[key] = value # Convert the dictionary back to a list of tuples legend_colors_edited = list(unique_tuples.items()) if not detailed_charge: # Regular expression pattern to remove isotope notation pattern_1 = r"\^{\d+\}|\{\+|\{-\}|\{\d+[+-]?\}" # Regular expression pattern to remove the isotope notation, charge, and caret ^ pattern_2 = r"\{\d+\}|[\^{}+-]" for i in range(len(legend_colors_edited)): legend_colors_edited[i] = (re.sub(pattern_1, "", legend_colors_edited[i][0]), legend_colors_edited[i][1]) legend_colors_edited[i] = (re.sub(pattern_2, "", legend_colors_edited[i][0]), legend_colors_edited[i][1]) # remove ununique labels # Using a set to track seen elements and filter out duplicates seen = set() unique_data = [] for item in legend_colors_edited: if item[0] not in seen: seen.add(item[0]) unique_data.append(item) legend_colors_edited = unique_data # Adjust the layout if len(legend_colors_edited) > 5: ncol = max(1, math.ceil(len(legend_colors_edited) / 8)) else: ncol = 1 self.ax.legend( [label[1] for label in legend_colors_edited], [label[0] for label in legend_colors_edited], loc=loc, ncol=ncol )
[docs] def plot_hist_info_legend(self, label='mc', mrp_all=False, background=None, legend_mode='long', loc='left'): """Plot summary legend info for histogram quality metrics.""" return _apply_hist_info_legend( self, label=label, mrp_all=mrp_all, background=background, legend_mode=legend_mode, loc=loc )
[docs] def mrp_calculation(self): """Calculate MRP metrics for current histogram peaks.""" return _calculate_mrp(self)
[docs] def plot_horizontal_lines(self): """ Plot the horizontal lines. Args: None Returns: None """ for i in range(len(self.variables.h_line_pos)): if np.max(self.mc_tof) + 10 > self.variables.h_line_pos[i] > np.max(self.mc_tof) - 10: plt.axvline(x=self.variables.h_line_pos[i], color='b', linestyle='--', linewidth=2)
[docs] def plot_background(self, mode, non_peaks=None, lam=1e6, tol=1e-1, max_iter=100, num_std=3.0, plot=True, patch=True): """Fit and plot histogram background.""" return _plot_background( self, mode, non_peaks=non_peaks, lam=lam, tol=tol, max_iter=max_iter, num_std=num_std, plot=plot, patch=patch )
[docs] def exponential_decay_with_linear_and_dc(self, x, a, b, c, d): """Exponential decay helper retained for compatibility.""" return _exp_decay(x, a, b, c, d)
[docs] def manual_background_fit( self, ): """Interactive manual background fitting.""" return _manual_background_fit(self)
[docs] def calculate_noise(self, fig_size=(9, 5), plot_without_noise=False): """Calculate noise after fitted background subtraction.""" return _calculate_noise(self, fig_size=fig_size, plot_without_noise=plot_without_noise)
[docs] def plot_founded_range_loc(self, df, remove_lines=False): """ Plot the founded range location. Args: df (data frame): The data frame of the founded range. remove_lines (bool): Whether to remove the lines. Returns: None """ if remove_lines or self.plotted_lines: # Remove previously plotted lines,circles and labels for line, circle, label in zip(self.plotted_lines, self.plotted_circles, self.plotted_labels): line.remove() circle[0].remove() label.remove() # Clear the lists self.plotted_lines.clear() self.plotted_circles.clear() self.plotted_labels.clear() elif not remove_lines: ax1 = self.ax.twinx() ions = df['ion'] abundances = df['abundance'] mass = df['mass'] # Define the scaling factor for the abundance to control the line height scaling_factor = 1.0 # Adjust as needed for ion, abundance, m in zip(ions, abundances, mass): # Calculate the height of the line based on abundance line_height = abundance * scaling_factor # Plot a vertical line at the position of 'mass' with the specified height line = ax1.vlines(x=m, ymin=0, ymax=line_height, color='red', linestyles='dashed') # Plot an empty circle marker at the top of the line circle = ax1.plot(m, line_height, marker='o', markersize=6, color='white', markeredgecolor='red') # Annotate the ion label (LaTeX formula) near the circle label = ax1.annotate( ion, xy=(m, line_height), xytext=(m, line_height), fontsize=10, color='blue', annotation_clip='clip_on', textcoords="offset points", xycoords="data", ) self.plotted_lines.append(line) # Keep track of the plotted lines self.plotted_circles.append(circle) # Keep track of the plotted circles self.plotted_labels.append(label) # Keep track of the plotted labels # Remove the y-axis and labels ax1.get_yaxis().set_visible(False) # Set the y-axis to log scale ax1.set_yscale('log')
[docs] def find_peaks_and_widths(self, prominence=None, distance=None, percent=50): """Find peaks and widths and update shared variables.""" return _find_peaks_and_widths(self, prominence=prominence, distance=distance, percent=percent)
[docs] def draw_rectangle(self, initial=False): """Draw auto-selected peak rectangle.""" return _draw_rectangle(self, initial=initial)
[docs] def selector(self, selector='rect'): """Attach interaction selector handlers.""" return _attach_selector(self, selector=selector)
[docs] def zoom_to_x_range(self, x_min, x_max, reset=False): """Zoom the histogram to a selected x-range or reset view.""" return _zoom_to_x_range(self, x_min, x_max, reset=reset)
[docs] def adjust_labels(self): """ Adjust the labels. Args: None Returns: None """ adjust_text(self.peak_annotates)
[docs] def save_fig(self, label, fig_name): """ Save the figure. Args: label (str): The label of the x-axis ('mc' or 'tof'). fig_name (str): The name of the figure. Returns: None """ if label == 'mc' or label == 'mc_c': save_figure( self.fig, directory=self.variables.result_path, stem=f"mc_{fig_name}", formats=("pdf", "png"), dpi=600, ) elif label == 'tof' or label == 'tof_c': save_figure( self.fig, directory=self.variables.result_path, stem=f"tof_{fig_name}", formats=("pdf", "png"), dpi=600, )
[docs] def hist_plot( variables, bin_size, log, target, normalize, prominence, distance, percent, selector, figname, lim, peaks_find=True, peaks_find_plot=False, plot_ranged_peak=False, plot_ranged_colors=False, mrp_all=False, background=None, grid=False, ranging_mode=False, range_sequence=[], range_mc=[], range_detx=[], range_dety=[], range_x=[], range_y=[], range_z=[], range_vol=[], save_fig=True, print_info=True, legend_mode='long', draw_calib_rect=False, figure_size=(9, 5), plot_show=True, fast_calibration=False, fast_histogram=True, initial_peak_selection=False, compute_mrp=True, ): """Backward-compatible wrapper delegating to :mod:`mc_plot_api`.""" from pyccapt.calibration.core.mc_plot_api import hist_plot as _hist_plot return _hist_plot( variables, bin_size, log, target, normalize, prominence, distance, percent, selector, figname, lim, peaks_find=peaks_find, peaks_find_plot=peaks_find_plot, plot_ranged_peak=plot_ranged_peak, plot_ranged_colors=plot_ranged_colors, mrp_all=mrp_all, background=background, grid=grid, ranging_mode=ranging_mode, range_sequence=range_sequence, range_mc=range_mc, range_detx=range_detx, range_dety=range_dety, range_x=range_x, range_y=range_y, range_z=range_z, range_vol=range_vol, save_fig=save_fig, print_info=print_info, legend_mode=legend_mode, draw_calib_rect=draw_calib_rect, figure_size=figure_size, plot_show=plot_show, fast_calibration=fast_calibration, fast_histogram=fast_histogram, initial_peak_selection=initial_peak_selection, compute_mrp=compute_mrp, )