Spaces:
Running
Running
| from __future__ import annotations | |
| import logging | |
| import os | |
| import sys | |
| from typing import Optional | |
| import numpy as np | |
| import numpy.typing as npt | |
| current_dir = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, os.path.join(current_dir, '..', 'lib')) | |
| from pythoms.tome import autoresolution | |
| logger = logging.getLogger(__name__) | |
| class SpectrumMixin: | |
| """Mixin for spectrum parsing and peak detection methods.""" | |
| def parse_txt_spectrum(self, txt_content: str) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: | |
| """ | |
| Parse mass spectrum from txt file | |
| Expected format: two columns (m/z, intensity) separated by whitespace or comma | |
| """ | |
| lines = txt_content.strip().replace('\r\n', '\n').replace('\r', '\n').split('\n') | |
| mz_values = [] | |
| intensity_values = [] | |
| for line in lines: | |
| line = line.strip() | |
| if not line or line.startswith('#'): | |
| continue | |
| # Try different delimiters | |
| parts = None | |
| if '\t' in line: | |
| parts = line.split('\t') | |
| elif ',' in line: | |
| parts = line.split(',') | |
| else: | |
| parts = line.split() | |
| if len(parts) >= 2: | |
| try: | |
| mz = float(parts[0]) | |
| intensity = float(parts[1]) | |
| mz_values.append(mz) | |
| intensity_values.append(intensity) | |
| except ValueError: | |
| continue | |
| return np.array(mz_values), np.array(intensity_values) | |
| def estimate_resolution(self, mz_values: npt.NDArray[np.float64], intensity_values: npt.NDArray[np.float64]) -> int: | |
| """ | |
| Estimate the resolution of the spectrum using PythoMS methodology | |
| """ | |
| try: | |
| res = autoresolution(list(mz_values), list(intensity_values), n=10, v=False) | |
| if res is None or not np.isfinite(res) or res <= 0: | |
| return 20000 # Default resolution | |
| return int(res) | |
| except Exception: | |
| return 20000 # Default resolution if estimation fails | |
| def calculate_fwhm(self, mz: float, resolution: int) -> float: | |
| """ | |
| Calculate Full Width at Half Maximum for a given m/z and resolution | |
| FWHM = m/z / resolution | |
| """ | |
| return mz / resolution | |
| def find_local_maximum( | |
| self, | |
| mz_values: npt.NDArray[np.float64], | |
| intensity_values: npt.NDArray[np.float64], | |
| center_mz: float, | |
| lookwithin: Optional[float] = None, | |
| ) -> tuple[Optional[float], Optional[float]]: | |
| """ | |
| Find the local maximum within a window around center_mz | |
| Based on PythoMS localmax function | |
| """ | |
| if lookwithin is None: | |
| lookwithin = 1.0 | |
| # Find indices within the window | |
| left_idx = np.searchsorted(mz_values, center_mz - lookwithin, side='left') | |
| right_idx = np.searchsorted(mz_values, center_mz + lookwithin, side='right') | |
| if left_idx >= right_idx: | |
| return None, None | |
| # Find maximum in the window | |
| window_intensities = intensity_values[left_idx:right_idx] | |
| if len(window_intensities) == 0: | |
| return None, None | |
| max_intensity = np.max(window_intensities) | |
| max_idx_in_window = np.argmax(window_intensities) | |
| max_idx = left_idx + max_idx_in_window | |
| return mz_values[max_idx], max_intensity | |
| def find_peak_regions( | |
| self, | |
| mz_values: npt.NDArray[np.float64], | |
| intensity_values: npt.NDArray[np.float64], | |
| threshold: float = 0.05, | |
| merge_gap: float = 1.5, | |
| ) -> list[tuple[int, int]]: | |
| """ | |
| Find isotope envelope regions - each ENVELOPE is one region | |
| Merges nearby regions that are likely part of the same isotope envelope | |
| merge_gap: merge regions separated by less than this m/z (default 1.5) | |
| """ | |
| norm_intensity = intensity_values / np.max(intensity_values) | |
| # Find regions above threshold | |
| above_threshold = norm_intensity > threshold | |
| # Find all continuous regions above threshold | |
| regions = [] | |
| in_region = False | |
| start_idx = 0 | |
| for i in range(len(above_threshold)): | |
| if above_threshold[i] and not in_region: | |
| # Start of new region | |
| start_idx = i | |
| in_region = True | |
| elif not above_threshold[i] and in_region: | |
| # End of region | |
| end_idx = i - 1 | |
| regions.append((start_idx, end_idx)) | |
| in_region = False | |
| # Handle case where last region extends to end | |
| if in_region: | |
| regions.append((start_idx, len(above_threshold) - 1)) | |
| # Merge regions that are close together (likely same isotope envelope) | |
| if len(regions) <= 1: | |
| return regions | |
| merged_regions = [] | |
| current_start, current_end = regions[0] | |
| for i in range(1, len(regions)): | |
| next_start, next_end = regions[i] | |
| # Check gap between current region end and next region start | |
| gap = mz_values[next_start] - mz_values[current_end] | |
| if gap < merge_gap: | |
| # Merge: extend current region to include next region | |
| current_end = next_end | |
| else: | |
| # Don't merge: save current region and start new one | |
| merged_regions.append((current_start, current_end)) | |
| current_start, current_end = next_start, next_end | |
| # Add the last region | |
| merged_regions.append((current_start, current_end)) | |
| return merged_regions | |
| def detect_peak_boundaries( | |
| self, mz_array: npt.NDArray[np.float64], int_array: npt.NDArray[np.float64], peak_mz: float | |
| ) -> tuple[float, float, float]: | |
| """ | |
| Detect the boundaries of a single isotope envelope from experimental data. | |
| NEW APPROACH: | |
| 1. Find APEX (highest point) in small window around clicked position | |
| 2. From apex, scan left/right to find valleys (local minima) | |
| 3. This ensures we identify the correct peak without jumping to adjacent ones | |
| Returns: (left_boundary_mz, right_boundary_mz, apex_mz) | |
| """ | |
| # Find the index closest to the clicked peak | |
| peak_idx = np.argmin(np.abs(mz_array - peak_mz)) | |
| # STEP 1: Find the APEX (highest point) in a SMALL window around clicked position | |
| # Use small window (±10 points) to avoid jumping to adjacent peaks | |
| search_window = 10 # Small window to stay on same peak | |
| search_start = max(0, peak_idx - search_window) | |
| search_end = min(len(int_array), peak_idx + search_window) | |
| # Find apex within this small region | |
| local_region_intensities = int_array[search_start:search_end] | |
| apex_idx_in_region = np.argmax(local_region_intensities) | |
| apex_idx = search_start + apex_idx_in_region | |
| apex_mz = mz_array[apex_idx] | |
| apex_intensity = int_array[apex_idx] | |
| logger.debug(f'Detecting isotope envelope boundaries around clicked m/z={peak_mz:.4f}') | |
| logger.debug(f'Clicked at index={peak_idx}, mz={mz_array[peak_idx]:.4f}') | |
| logger.debug(f'Found APEX at index={apex_idx}, mz={apex_mz:.4f}, intensity={apex_intensity:.0f}') | |
| # STEP 2: From APEX, scan LEFT to find valley (local minimum) | |
| left_idx = apex_idx | |
| min_intensity_left = apex_intensity | |
| for i in range(apex_idx - 1, max(0, apex_idx - 200), -1): | |
| current_intensity = int_array[i] | |
| # Track the minimum intensity as we scan left | |
| if current_intensity < min_intensity_left: | |
| min_intensity_left = current_intensity | |
| left_idx = i | |
| # Stop if intensity starts rising significantly (found the valley) | |
| # Look for 2 consecutive points rising by >10% | |
| if i >= 1: | |
| if int_array[i - 1] > current_intensity * 1.1 and int_array[i] > int_array[i + 1] * 1.1: | |
| # Found a valley - intensity is rising on the left | |
| logger.debug( | |
| f'Left valley at index={left_idx}, mz={mz_array[left_idx]:.4f}, intensity={int_array[left_idx]:.0f}' | |
| ) | |
| break | |
| # If we hit the edge without finding a valley, use the minimum we found | |
| if left_idx == apex_idx: | |
| logger.debug(f'Left boundary at edge: index={left_idx}, mz={mz_array[left_idx]:.4f}') | |
| # STEP 3: From APEX, scan RIGHT to find valley (local minimum) | |
| right_idx = apex_idx | |
| min_intensity_right = apex_intensity | |
| for i in range(apex_idx + 1, min(len(int_array), apex_idx + 200)): | |
| current_intensity = int_array[i] | |
| # Track the minimum intensity as we scan right | |
| if current_intensity < min_intensity_right: | |
| min_intensity_right = current_intensity | |
| right_idx = i | |
| # Stop if intensity starts rising significantly (found the valley) | |
| # Look for 2 consecutive points rising by >10% | |
| if i < len(int_array) - 1: | |
| if int_array[i + 1] > current_intensity * 1.1 and int_array[i] > int_array[i - 1] * 1.1: | |
| # Found a valley - intensity is rising on the right | |
| logger.debug( | |
| f'Right valley at index={right_idx}, mz={mz_array[right_idx]:.4f}, intensity={int_array[right_idx]:.0f}' | |
| ) | |
| break | |
| # If we hit the edge without finding a valley, use the minimum we found | |
| if right_idx == apex_idx: | |
| logger.debug(f'Right boundary at edge: index={right_idx}, mz={mz_array[right_idx]:.4f}') | |
| left_boundary_mz = mz_array[left_idx] | |
| right_boundary_mz = mz_array[right_idx] | |
| width = right_boundary_mz - left_boundary_mz | |
| num_points = right_idx - left_idx + 1 | |
| logger.debug( | |
| f'Final envelope: [{left_boundary_mz:.4f}, {right_boundary_mz:.4f}] m/z (width={width:.4f}, {num_points} points)' | |
| ) | |
| logger.debug(f'Apex at {apex_mz:.4f} (Gaussian will use MIDPOINT of boundaries as initial guess)') | |
| return left_boundary_mz, right_boundary_mz, apex_mz | |
| def weighted_centroid( | |
| self, | |
| mz_values: npt.NDArray[np.float64], | |
| intensity_values: npt.NDArray[np.float64], | |
| start_idx: int, | |
| end_idx: int, | |
| ) -> tuple[Optional[float], Optional[float]]: | |
| """ | |
| Calculate peak centroid (position of maximum intensity) matching PythoMS isotope overlay method | |
| This uses the m/z value at maximum intensity for peak position, which is consistent | |
| with how PythoMS's plot_mass_spectrum and localmax functions work. | |
| """ | |
| region_mz = mz_values[start_idx : end_idx + 1] | |
| region_int = intensity_values[start_idx : end_idx + 1] | |
| if len(region_mz) == 0 or np.sum(region_int) == 0: | |
| return None, None | |
| # Find the m/z at maximum intensity (peak apex) | |
| # This matches PythoMS isotope overlay behavior | |
| max_idx = np.argmax(region_int) | |
| centroid_mz = region_mz[max_idx] | |
| max_intensity = region_int[max_idx] | |
| return centroid_mz, max_intensity | |