NucleoSpec / core /spectrum.py
viviandlin
Sync from GitHub: Refactor analyzer into mixin modules for maintainability
98c5587
Raw
History Blame Contribute Delete
11.4 kB
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