File size: 11,406 Bytes
98c5587
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
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