File size: 10,238 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
from __future__ import annotations

import logging
from typing import Optional

import numpy as np
import numpy.typing as npt

logger = logging.getLogger(__name__)


class ScoringMixin:
    """Mixin for isotope pattern matching and similarity scoring."""

    def calculate_pattern_similarity(
        self,
        theo_mz: npt.NDArray[np.float64],
        theo_int: npt.NDArray[np.float64],
        exp_mz: npt.NDArray[np.float64],
        exp_int: npt.NDArray[np.float64],
        window: float = 3.0,
    ) -> float:
        """Mean of cosine similarity and Pearson correlation between matched theo/exp isotope peaks."""
        try:
            if len(theo_mz) == 0 or len(exp_mz) == 0:
                return 0.0

            from scipy.signal import find_peaks

            theo_mz = np.array(theo_mz, dtype=np.float64)
            theo_int = np.array(theo_int, dtype=np.float64)
            exp_mz = np.array(exp_mz, dtype=np.float64)
            exp_int = np.array(exp_int, dtype=np.float64)

            # Filter to significant sticks (>5% of max)
            sig_mask = theo_int > np.max(theo_int) * 0.05
            theo_mz = theo_mz[sig_mask]
            theo_int = theo_int[sig_mask]
            if len(theo_mz) < 2:
                return 0.0

            # Find experimental peak apexes
            spacing = np.median(np.diff(theo_mz))
            min_distance = int(spacing * 0.4 / np.median(np.diff(exp_mz))) if len(exp_mz) > 1 else 2
            peaks_idx, _ = find_peaks(exp_int, distance=max(2, min_distance), prominence=np.max(exp_int) * 0.02)

            if len(peaks_idx) < 2:
                apex_mz = exp_mz
                apex_int = exp_int
            else:
                apex_mz = exp_mz[peaks_idx]
                apex_int = exp_int[peaks_idx]

            # Match each stick to nearest apex within half-spacing tolerance
            match_tol = spacing * 0.5
            paired_theo = []
            paired_exp = []

            for i in range(len(theo_mz)):
                diffs = np.abs(apex_mz - theo_mz[i])
                nearest_idx = np.argmin(diffs)
                if diffs[nearest_idx] <= match_tol:
                    paired_theo.append(theo_int[i])
                    paired_exp.append(apex_int[nearest_idx])
                else:
                    paired_theo.append(theo_int[i])
                    paired_exp.append(0.0)

            if len(paired_theo) < 2:
                return 0.0

            paired_theo = np.array(paired_theo)
            paired_exp = np.array(paired_exp)

            # Normalize to max = 1
            paired_theo = paired_theo / (np.max(paired_theo) + 1e-10)
            paired_exp = paired_exp / (np.max(paired_exp) + 1e-10)

            # Cosine similarity
            cosine_sim = np.dot(paired_theo, paired_exp) / (
                np.linalg.norm(paired_theo) * np.linalg.norm(paired_exp) + 1e-10
            )
            cosine_sim = max(0.0, min(1.0, cosine_sim))

            # Pearson correlation
            if np.std(paired_theo) > 0 and np.std(paired_exp) > 0:
                correlation = np.corrcoef(paired_theo, paired_exp)[0, 1]
                correlation = max(0.0, min(1.0, correlation))
            else:
                correlation = 0.0

            return float((cosine_sim + correlation) / 2.0)

        except Exception as e:
            logger.exception(f'[calculate_pattern_similarity] Exception: {str(e)}')
            return 0.0

    def calculate_multi_parameter_fit_score(
        self,
        theo_mz: npt.NDArray[np.float64],
        theo_int: npt.NDArray[np.float64],
        exp_mz: npt.NDArray[np.float64],
        exp_int: npt.NDArray[np.float64],
        theo_x0: Optional[float],
        theo_sigma: Optional[float],
        exp_x0: Optional[float],
        exp_sigma: Optional[float],
    ) -> tuple[float, dict]:
        """
        Calculate comprehensive fit score combining multiple parameters:
        1. X₀ error (centroid position)
        2. σ ratio (width matching)
        3. R² (curve overlap quality)

        Returns a composite score (lower is better) and individual metrics
        """
        try:
            if theo_x0 is None or exp_x0 is None or theo_sigma is None or exp_sigma is None:
                return 999.0, {'x0_error': 999.0, 'sigma_ratio': None, 'r_squared': None}

            # 1. X₀ error (absolute difference in centroid positions)
            x0_error = abs(exp_x0 - theo_x0)

            # 2. σ ratio (how well the widths match)
            # Ratio close to 1.0 means good width match
            sigma_ratio = theo_sigma / exp_sigma if exp_sigma > 0 else None
            sigma_deviation = abs(1.0 - sigma_ratio) if sigma_ratio else 999.0

            # 3. R² (coefficient of determination - curve overlap quality)
            # Need to align theoretical and experimental on same m/z grid
            try:
                # Find overlapping m/z range
                mz_min = max(np.min(theo_mz), np.min(exp_mz))
                mz_max = min(np.max(theo_mz), np.max(exp_mz))

                if mz_max <= mz_min:
                    r_squared = 0.0
                else:
                    # Create common m/z grid for comparison
                    mz_grid = np.linspace(mz_min, mz_max, 200)

                    # Interpolate both patterns onto common grid
                    theo_interp = np.interp(mz_grid, theo_mz, theo_int, left=0, right=0)
                    exp_interp = np.interp(mz_grid, exp_mz, exp_int, left=0, right=0)

                    # Normalize both to max=1 for fair comparison
                    theo_norm = theo_interp / np.max(theo_interp) if np.max(theo_interp) > 0 else theo_interp
                    exp_norm = exp_interp / np.max(exp_interp) if np.max(exp_interp) > 0 else exp_interp

                    # Calculate R² (coefficient of determination)
                    ss_res = np.sum((exp_norm - theo_norm) ** 2)  # Residual sum of squares
                    ss_tot = np.sum((exp_norm - np.mean(exp_norm)) ** 2)  # Total sum of squares
                    r_squared = 1.0 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
                    r_squared = max(0.0, min(1.0, r_squared))  # Clamp to [0, 1]

            except Exception as e:
                logger.error(f'[R-squared calculation failed]: {str(e)}')
                r_squared = 0.0

            # Composite score (weighted combination - lower is better)
            # Weight factors - adjust these based on importance
            w_x0 = 10.0  # X₀ error weight (m/z units)
            w_sigma = 5.0  # σ deviation weight
            w_r2 = 20.0  # R² weight (inverted since higher R² is better)

            composite_score = (
                w_x0 * x0_error  # Centroid position error
                + w_sigma * sigma_deviation  # Width mismatch
                + w_r2 * (1.0 - r_squared)  # Shape overlap quality (inverted)
            )

            metrics = {
                'x0_error': float(x0_error),
                'sigma_ratio': float(sigma_ratio) if sigma_ratio else None,
                'sigma_deviation': float(sigma_deviation),
                'r_squared': float(r_squared),
                'composite_score': float(composite_score),
            }

            logger.debug(
                f'[Fit Score] X0_err={x0_error:.4f}, sigma_ratio={sigma_ratio:.3f}, R_squared={r_squared:.4f}, Score={composite_score:.2f}'
            )

            return composite_score, metrics

        except Exception as e:
            logger.exception(f'[calculate_multi_parameter_fit_score] Exception: {str(e)}')
            return 999.0, {'x0_error': 999.0, 'sigma_ratio': None, 'r_squared': None}

    def match_isotope_pattern(
        self,
        experimental_mz: npt.NDArray[np.float64],
        experimental_int: npt.NDArray[np.float64],
        theoretical_pattern: dict,
        tolerance: float = 0.5,
    ) -> float:
        """
        Match experimental peaks to theoretical isotope pattern using Gaussian fitting
        Compares X0 (centroid) positions between theory and experiment
        Returns the X0 centroid difference in m/z units (error metric)
        """
        if 'error' in theoretical_pattern:
            return 999.0  # Large error if pattern generation failed

        # Use smooth Gaussian pattern for theo_x0 calculation (same method as exp_x0)
        theo_mz = np.array(theoretical_pattern.get('gaussian_mz', theoretical_pattern.get('mz', [])))
        theo_int = np.array(theoretical_pattern.get('gaussian_intensity', theoretical_pattern.get('intensity', [])))

        if len(theo_mz) == 0:
            return 999.0

        # Normalize both patterns
        theo_int_norm = theo_int / np.max(theo_int) * 100
        exp_int_norm = experimental_int / np.max(experimental_int) * 100

        # Calculate Gaussian centroids (X0) for both patterns
        theo_fit_result = self.gaussian_fit_centroid(theo_mz, theo_int_norm)
        exp_fit_result = self.gaussian_fit_centroid(experimental_mz, exp_int_norm)

        theo_x0 = theo_fit_result[0] if theo_fit_result else None
        exp_x0 = exp_fit_result[0] if exp_fit_result else None

        if theo_x0 is None or exp_x0 is None:
            return 999.0

        # Find experimental peak closest to each theoretical peak
        matched_intensities = []
        matched_masses = []

        for t_mz, t_int in zip(theo_mz, theo_int_norm):
            # Find experimental peaks within tolerance
            close_peaks = np.where(np.abs(experimental_mz - t_mz) < tolerance)[0]

            if len(close_peaks) > 0:
                # Find the closest peak
                closest_idx = close_peaks[np.argmin(np.abs(experimental_mz[close_peaks] - t_mz))]
                matched_intensities.append((t_int, exp_int_norm[closest_idx]))
                matched_masses.append((t_mz, experimental_mz[closest_idx]))

        if len(matched_intensities) == 0:
            return 999.0

        # Return X0 centroid difference in m/z units
        # This is the ERROR metric - smaller is better
        # Different Qcl values shift the centroid position
        # The Qcl with smallest X0 error is the correct one

        x0_error = abs(exp_x0 - theo_x0)

        return x0_error