masster 0.4.2__py3-none-any.whl → 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of masster might be problematic. Click here for more details.
- masster/_version.py +1 -1
- masster/lib/__init__.py +9 -0
- masster/lib/lib.py +598 -0
- masster/study/helpers.py +103 -8
- {masster-0.4.2.dist-info → masster-0.4.3.dist-info}/METADATA +791 -789
- {masster-0.4.2.dist-info → masster-0.4.3.dist-info}/RECORD +10 -20
- {masster-0.4.2.dist-info → masster-0.4.3.dist-info}/WHEEL +2 -1
- masster-0.4.3.dist-info/top_level.txt +1 -0
- masster/data/dda/20250530_VH_IQX_KW_RP_HSST3_100mm_12min_pos_v4_DDA_OT_C-MiLUT_QC_dil2_01_20250602151849.sample5 +0 -0
- masster/data/dda/20250530_VH_IQX_KW_RP_HSST3_100mm_12min_pos_v4_DDA_OT_C-MiLUT_QC_dil3_01_20250602150634.sample5 +0 -0
- masster/data/dda/20250530_VH_IQX_KW_RP_HSST3_100mm_12min_pos_v4_MS1_C-MiLUT_C008_v6_r38_01.sample5 +0 -0
- masster/data/dda/20250530_VH_IQX_KW_RP_HSST3_100mm_12min_pos_v4_MS1_C-MiLUT_C008_v7_r37_01.sample5 +0 -0
- masster/data/dda/20250530_VH_IQX_KW_RP_HSST3_100mm_12min_pos_v4_MS1_C-MiLUT_C017_v5_r99_01.sample5 +0 -0
- masster/data/libs/ccm.csv +0 -120
- masster/data/libs/urine.csv +0 -4693
- masster/data/wiff/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.timeseries.data +0 -0
- masster/data/wiff/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff +0 -0
- masster/data/wiff/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff.scan +0 -0
- masster/data/wiff/2025_01_14_VW_7600_LpMx_DBS_CID_2min_TOP15_030msecMS1_005msecReac_CE35_DBS-ON_3.wiff2 +0 -0
- masster/sample/sample5_schema.json +0 -196
- masster/study/study5_schema.json +0 -360
- {masster-0.4.2.dist-info → masster-0.4.3.dist-info}/entry_points.txt +0 -0
- {masster-0.4.2.dist-info → masster-0.4.3.dist-info}/licenses/LICENSE +0 -0
masster/_version.py
CHANGED
masster/lib/__init__.py
ADDED
masster/lib/lib.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
lib.py
|
|
3
|
+
|
|
4
|
+
This module provides the Lib class for mass spectrometry compound library
|
|
5
|
+
management and feature annotation.
|
|
6
|
+
|
|
7
|
+
The Lib class supports annotation of sample.features_df and study.consensus_df
|
|
8
|
+
based on MS1 (rt, m/z, possibly isotopes) and MS2 data.
|
|
9
|
+
|
|
10
|
+
Key Features:
|
|
11
|
+
- **Lib Class**: Main class for managing compound libraries and annotations
|
|
12
|
+
- **Compound Libraries**: Load and manage compound databases with metadata
|
|
13
|
+
- **Adduct Calculations**: Handle various ionization adducts and charge states
|
|
14
|
+
- **Mass Calculations**: Precise mass calculations with adduct corrections
|
|
15
|
+
- **Target Matching**: Match detected features against compound libraries
|
|
16
|
+
- **Polarity Handling**: Support for positive and negative ionization modes
|
|
17
|
+
- **CSV Import**: Import compound data from CSV files with automatic adduct generation
|
|
18
|
+
|
|
19
|
+
Dependencies:
|
|
20
|
+
- `pyopenms`: For mass spectrometry algorithms and data structures
|
|
21
|
+
- `polars`: For efficient data manipulation and analysis
|
|
22
|
+
- `numpy`: For numerical computations and array operations
|
|
23
|
+
- `uuid`: For generating unique identifiers
|
|
24
|
+
|
|
25
|
+
Supported Adducts:
|
|
26
|
+
- Positive mode: [M+H]+, [M+Na]+, [M+K]+, [M+NH4]+, [M-H2O+H]+
|
|
27
|
+
- Negative mode: [M-H]-, [M+CH3COO]-, [M+HCOO]-, [M+Cl]-
|
|
28
|
+
|
|
29
|
+
Example Usage:
|
|
30
|
+
```python
|
|
31
|
+
from masster.lib import Lib
|
|
32
|
+
|
|
33
|
+
# Create library instance
|
|
34
|
+
lib = Lib()
|
|
35
|
+
|
|
36
|
+
# Import compounds from CSV
|
|
37
|
+
lib.import_csv("compounds.csv", polarity="positive")
|
|
38
|
+
|
|
39
|
+
# Access library data
|
|
40
|
+
print(f"Loaded {len(lib.lib_df)} compounds")
|
|
41
|
+
print(lib.lib_df.head())
|
|
42
|
+
|
|
43
|
+
# Annotate sample features
|
|
44
|
+
annotations = lib.annotate_features(sample.features_df)
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import os
|
|
49
|
+
import uuid
|
|
50
|
+
from typing import Optional, Union, List, Dict, Any, TYPE_CHECKING
|
|
51
|
+
import warnings
|
|
52
|
+
|
|
53
|
+
import numpy as np
|
|
54
|
+
import polars as pl
|
|
55
|
+
import pyopenms as oms
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
import pandas as pd
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Lib:
|
|
62
|
+
"""
|
|
63
|
+
A class for managing compound libraries and feature annotation in mass spectrometry data.
|
|
64
|
+
|
|
65
|
+
The Lib class provides functionality to:
|
|
66
|
+
- Load compound libraries from CSV files
|
|
67
|
+
- Generate adduct variants for compounds
|
|
68
|
+
- Annotate MS1 features based on mass and retention time
|
|
69
|
+
- Support both positive and negative ionization modes
|
|
70
|
+
- Manage compound metadata (SMILES, InChI, formulas, etc.)
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
lib_df (pl.DataFrame): Polars DataFrame containing the library data with columns:
|
|
74
|
+
- lib_uid: Unique identifier for each library entry
|
|
75
|
+
- name: Compound name
|
|
76
|
+
- smiles: SMILES notation
|
|
77
|
+
- inchi: InChI identifier
|
|
78
|
+
- inchikey: InChI key
|
|
79
|
+
- formula: Molecular formula
|
|
80
|
+
- adduct: Adduct type
|
|
81
|
+
- m: Mass with adduct
|
|
82
|
+
- z: Charge state
|
|
83
|
+
- mz: Mass-to-charge ratio
|
|
84
|
+
- rt: Retention time (if available)
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> lib = Lib()
|
|
88
|
+
>>> lib.import_csv("compounds.csv", polarity="positive")
|
|
89
|
+
>>> print(f"Loaded {len(lib.lib_df)} library entries")
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
# Define supported adducts and their properties
|
|
93
|
+
ADDUCT_DEFINITIONS = {
|
|
94
|
+
# Positive mode adducts
|
|
95
|
+
"[M+H]1+": {"delta_m": 1.007276, "delta_z": 1, "polarity": "positive"},
|
|
96
|
+
"[M+Na]1+": {"delta_m": 22.989218, "delta_z": 1, "polarity": "positive"},
|
|
97
|
+
"[M+K]1+": {"delta_m": 38.962383, "delta_z": 1, "polarity": "positive"},
|
|
98
|
+
"[M+NH4]1+": {"delta_m": 18.033823, "delta_z": 1, "polarity": "positive"},
|
|
99
|
+
"[M+H-H2O]1+": {"delta_m": -17.00329, "delta_z": 1, "polarity": "positive"},
|
|
100
|
+
"[M+2H]2+": {"delta_m": 2.014552, "delta_z": 2, "polarity": "positive"},
|
|
101
|
+
|
|
102
|
+
# Negative mode adducts
|
|
103
|
+
"[M-H]1-": {"delta_m": -1.007276, "delta_z": -1, "polarity": "negative"},
|
|
104
|
+
"[M+CH3COO]1-": {"delta_m": 59.013852, "delta_z": -1, "polarity": "negative"},
|
|
105
|
+
"[M+HCOO]1-": {"delta_m": 44.998203, "delta_z": -1, "polarity": "negative"},
|
|
106
|
+
"[M+Cl]1-": {"delta_m": 34.968853, "delta_z": -1, "polarity": "negative"},
|
|
107
|
+
"[M-2H]2-": {"delta_m": -2.014552, "delta_z": -2, "polarity": "negative"},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def __init__(self):
|
|
111
|
+
"""Initialize an empty Lib instance."""
|
|
112
|
+
self.lib_df = None
|
|
113
|
+
self._initialize_empty_dataframe()
|
|
114
|
+
|
|
115
|
+
def _initialize_empty_dataframe(self):
|
|
116
|
+
"""Initialize an empty DataFrame with the required schema."""
|
|
117
|
+
self.lib_df = pl.DataFrame({
|
|
118
|
+
"lib_uid": pl.Series([], dtype=pl.Int64),
|
|
119
|
+
"cmpd_uid": pl.Series([], dtype=pl.Int64),
|
|
120
|
+
"source_id": pl.Series([], dtype=pl.Utf8),
|
|
121
|
+
"name": pl.Series([], dtype=pl.Utf8),
|
|
122
|
+
"smiles": pl.Series([], dtype=pl.Utf8),
|
|
123
|
+
"inchi": pl.Series([], dtype=pl.Utf8),
|
|
124
|
+
"inchikey": pl.Series([], dtype=pl.Utf8),
|
|
125
|
+
"formula": pl.Series([], dtype=pl.Utf8),
|
|
126
|
+
"adduct": pl.Series([], dtype=pl.Utf8),
|
|
127
|
+
"m": pl.Series([], dtype=pl.Float64),
|
|
128
|
+
"z": pl.Series([], dtype=pl.Int8),
|
|
129
|
+
"mz": pl.Series([], dtype=pl.Float64),
|
|
130
|
+
"rt": pl.Series([], dtype=pl.Float64),
|
|
131
|
+
"db_id": pl.Series([], dtype=pl.Utf8),
|
|
132
|
+
"db": pl.Series([], dtype=pl.Utf8),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
def _calculate_accurate_mass(self, formula: str) -> Optional[float]:
|
|
136
|
+
"""
|
|
137
|
+
Calculate the accurate mass for a molecular formula using PyOpenMS.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
formula: Molecular formula string
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Accurate mass as float, or None if calculation fails
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
empirical_formula = oms.EmpiricalFormula(formula)
|
|
147
|
+
return empirical_formula.getMonoWeight()
|
|
148
|
+
except Exception as e:
|
|
149
|
+
warnings.warn(f"Error calculating accurate mass for formula {formula}: {e}")
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _generate_adduct_variants(self,
|
|
153
|
+
compound_data: Dict[str, Any],
|
|
154
|
+
adducts: Optional[List[str]] = None,
|
|
155
|
+
polarity: Optional[str] = None,
|
|
156
|
+
lib_id_counter: Optional[int] = None) -> tuple[List[Dict[str, Any]], int]:
|
|
157
|
+
"""
|
|
158
|
+
Generate adduct variants for a given compound.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
compound_data: Dictionary containing compound information
|
|
162
|
+
adducts: List of specific adducts to generate. If None, uses all adducts for polarity
|
|
163
|
+
polarity: Ionization polarity ("positive", "negative", or None for both)
|
|
164
|
+
lib_id_counter: Counter for generating unique lib_uid values
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Tuple of (list of dictionaries representing adduct variants, updated counter)
|
|
168
|
+
"""
|
|
169
|
+
variants = []
|
|
170
|
+
counter = lib_id_counter or 1
|
|
171
|
+
|
|
172
|
+
# Calculate base accurate mass
|
|
173
|
+
accurate_mass = self._calculate_accurate_mass(compound_data["formula"])
|
|
174
|
+
if accurate_mass is None:
|
|
175
|
+
return variants, counter
|
|
176
|
+
|
|
177
|
+
# Determine which adducts to use
|
|
178
|
+
if adducts is None:
|
|
179
|
+
if polarity is None:
|
|
180
|
+
# Use all adducts
|
|
181
|
+
selected_adducts = list(self.ADDUCT_DEFINITIONS.keys())
|
|
182
|
+
else:
|
|
183
|
+
# Filter by polarity
|
|
184
|
+
selected_adducts = [
|
|
185
|
+
adduct for adduct, props in self.ADDUCT_DEFINITIONS.items()
|
|
186
|
+
if props["polarity"] == polarity.lower()
|
|
187
|
+
]
|
|
188
|
+
else:
|
|
189
|
+
selected_adducts = adducts
|
|
190
|
+
|
|
191
|
+
# Generate variants for each adduct
|
|
192
|
+
for adduct in selected_adducts:
|
|
193
|
+
if adduct not in self.ADDUCT_DEFINITIONS:
|
|
194
|
+
warnings.warn(f"Unknown adduct: {adduct}")
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
adduct_props = self.ADDUCT_DEFINITIONS[adduct]
|
|
198
|
+
|
|
199
|
+
# Skip if polarity doesn't match
|
|
200
|
+
if polarity is not None and adduct_props["polarity"] != polarity.lower():
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Calculate adducted mass and m/z
|
|
204
|
+
adducted_mass = accurate_mass + adduct_props["delta_m"]
|
|
205
|
+
charge = adduct_props["delta_z"]
|
|
206
|
+
mz = abs(adducted_mass / charge) if charge != 0 else adducted_mass
|
|
207
|
+
|
|
208
|
+
# Create variant entry
|
|
209
|
+
variant = {
|
|
210
|
+
"lib_uid": counter,
|
|
211
|
+
"cmpd_uid": compound_data.get("cmpd_uid", None),
|
|
212
|
+
"source_id": compound_data.get("source_id", None),
|
|
213
|
+
"name": compound_data.get("name", ""),
|
|
214
|
+
"smiles": compound_data.get("smiles", ""),
|
|
215
|
+
"inchi": compound_data.get("inchi", ""),
|
|
216
|
+
"inchikey": compound_data.get("inchikey", ""),
|
|
217
|
+
"formula": compound_data["formula"],
|
|
218
|
+
"adduct": adduct,
|
|
219
|
+
"m": adducted_mass,
|
|
220
|
+
"z": charge,
|
|
221
|
+
"mz": mz,
|
|
222
|
+
"rt": compound_data.get("rt", None),
|
|
223
|
+
"db_id": compound_data.get("db_id", None),
|
|
224
|
+
"db": compound_data.get("db", None),
|
|
225
|
+
}
|
|
226
|
+
variants.append(variant)
|
|
227
|
+
counter += 1
|
|
228
|
+
|
|
229
|
+
return variants, counter
|
|
230
|
+
|
|
231
|
+
def import_csv(self,
|
|
232
|
+
csvfile: str,
|
|
233
|
+
polarity: Optional[str] = None,
|
|
234
|
+
adducts: Optional[List[str]] = None) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Import compound library from a CSV file.
|
|
237
|
+
|
|
238
|
+
This method reads a CSV file and generates adduct variants for each compound.
|
|
239
|
+
Missing columns will be filled with appropriate default values.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
csvfile: Path to the CSV file
|
|
243
|
+
polarity: Ionization polarity ("positive", "negative", or None for both)
|
|
244
|
+
adducts: Specific adducts to generate. If None, generates all for the polarity
|
|
245
|
+
|
|
246
|
+
Expected CSV columns (case-insensitive):
|
|
247
|
+
- Required: Formula (or formula)
|
|
248
|
+
- Optional: Name/name/Compound/compound, SMILES/smiles, InChI/inchi,
|
|
249
|
+
InChIKey/inchikey, RT/rt, RT2/rt2
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
FileNotFoundError: If CSV file doesn't exist
|
|
253
|
+
ValueError: If required columns are missing
|
|
254
|
+
"""
|
|
255
|
+
if not os.path.exists(csvfile):
|
|
256
|
+
raise FileNotFoundError(f"CSV file not found: {csvfile}")
|
|
257
|
+
|
|
258
|
+
# Read CSV file with robust error handling
|
|
259
|
+
try:
|
|
260
|
+
df = pl.read_csv(csvfile, truncate_ragged_lines=True, ignore_errors=True)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
raise ValueError(f"Error reading CSV file: {e}") from e
|
|
263
|
+
|
|
264
|
+
# Find column mappings (case-insensitive)
|
|
265
|
+
column_mapping = self._map_csv_columns(df.columns)
|
|
266
|
+
|
|
267
|
+
# Validate required columns
|
|
268
|
+
if "formula" not in column_mapping:
|
|
269
|
+
raise ValueError("Required column 'Formula' (or 'formula') not found in CSV file")
|
|
270
|
+
|
|
271
|
+
# Process each compound
|
|
272
|
+
all_variants = []
|
|
273
|
+
cmpd_id_counter = 1
|
|
274
|
+
lib_id_counter = 1
|
|
275
|
+
|
|
276
|
+
for row in df.iter_rows(named=True):
|
|
277
|
+
# Extract compound data
|
|
278
|
+
# assign a compound-level uid so all adducts share the same cmpd_uid
|
|
279
|
+
compound_level_uid = cmpd_id_counter
|
|
280
|
+
cmpd_id_counter += 1
|
|
281
|
+
|
|
282
|
+
compound_data = {
|
|
283
|
+
"name": row.get(column_mapping.get("name", ""), ""),
|
|
284
|
+
"smiles": row.get(column_mapping.get("smiles", ""), ""),
|
|
285
|
+
"inchi": row.get(column_mapping.get("inchi", ""), ""),
|
|
286
|
+
"inchikey": row.get(column_mapping.get("inchikey", ""), ""),
|
|
287
|
+
"formula": row[column_mapping["formula"]],
|
|
288
|
+
"rt": self._safe_float_conversion(row.get(column_mapping.get("rt", ""), None)),
|
|
289
|
+
"db_id": row.get(column_mapping.get("db_id", ""), None),
|
|
290
|
+
"db": row.get(column_mapping.get("db", ""), None),
|
|
291
|
+
"cmpd_uid": compound_level_uid,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# Generate adduct variants
|
|
295
|
+
variants, lib_id_counter = self._generate_adduct_variants(
|
|
296
|
+
compound_data, adducts=adducts, polarity=polarity, lib_id_counter=lib_id_counter
|
|
297
|
+
)
|
|
298
|
+
all_variants.extend(variants)
|
|
299
|
+
|
|
300
|
+
# Handle RT2 column if present
|
|
301
|
+
if "rt2" in column_mapping:
|
|
302
|
+
rt2_value = self._safe_float_conversion(row.get(column_mapping["rt2"], None))
|
|
303
|
+
if rt2_value is not None:
|
|
304
|
+
# Create additional variants with RT2
|
|
305
|
+
compound_data_rt2 = compound_data.copy()
|
|
306
|
+
compound_data_rt2["rt"] = rt2_value
|
|
307
|
+
compound_data_rt2["name"] = compound_data["name"] + " II"
|
|
308
|
+
|
|
309
|
+
variants_rt2, lib_id_counter = self._generate_adduct_variants(
|
|
310
|
+
compound_data_rt2, adducts=adducts, polarity=polarity, lib_id_counter=lib_id_counter
|
|
311
|
+
)
|
|
312
|
+
all_variants.extend(variants_rt2)
|
|
313
|
+
|
|
314
|
+
# Convert to DataFrame and store
|
|
315
|
+
if all_variants:
|
|
316
|
+
new_lib_df = pl.DataFrame(all_variants)
|
|
317
|
+
|
|
318
|
+
# Combine with existing data if any
|
|
319
|
+
if self.lib_df is not None and len(self.lib_df) > 0:
|
|
320
|
+
self.lib_df = pl.concat([self.lib_df, new_lib_df])
|
|
321
|
+
else:
|
|
322
|
+
self.lib_df = new_lib_df
|
|
323
|
+
|
|
324
|
+
print(f"Successfully imported {len(all_variants)} library entries from {csvfile}")
|
|
325
|
+
else:
|
|
326
|
+
print(f"No valid compounds found in {csvfile}")
|
|
327
|
+
|
|
328
|
+
def _map_csv_columns(self, columns: List[str]) -> Dict[str, str]:
|
|
329
|
+
"""
|
|
330
|
+
Map CSV column names to standardized internal names (case-insensitive).
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
columns: List of column names from CSV
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Dictionary mapping internal names to actual column names
|
|
337
|
+
"""
|
|
338
|
+
mapping = {}
|
|
339
|
+
columns_lower = [col.lower() for col in columns]
|
|
340
|
+
|
|
341
|
+
# Name mapping
|
|
342
|
+
for name_variant in ["name", "compound"]:
|
|
343
|
+
if name_variant in columns_lower:
|
|
344
|
+
mapping["name"] = columns[columns_lower.index(name_variant)]
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
# Formula mapping
|
|
348
|
+
for formula_variant in ["formula"]:
|
|
349
|
+
if formula_variant in columns_lower:
|
|
350
|
+
mapping["formula"] = columns[columns_lower.index(formula_variant)]
|
|
351
|
+
break
|
|
352
|
+
|
|
353
|
+
# SMILES mapping
|
|
354
|
+
for smiles_variant in ["smiles"]:
|
|
355
|
+
if smiles_variant in columns_lower:
|
|
356
|
+
mapping["smiles"] = columns[columns_lower.index(smiles_variant)]
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
# InChI mapping
|
|
360
|
+
for inchi_variant in ["inchi"]:
|
|
361
|
+
if inchi_variant in columns_lower:
|
|
362
|
+
mapping["inchi"] = columns[columns_lower.index(inchi_variant)]
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
# InChIKey mapping
|
|
366
|
+
for inchikey_variant in ["inchikey", "inchi_key"]:
|
|
367
|
+
if inchikey_variant in columns_lower:
|
|
368
|
+
mapping["inchikey"] = columns[columns_lower.index(inchikey_variant)]
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
# RT mapping
|
|
372
|
+
for rt_variant in ["rt", "retention_time", "retentiontime"]:
|
|
373
|
+
if rt_variant in columns_lower:
|
|
374
|
+
mapping["rt"] = columns[columns_lower.index(rt_variant)]
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
# RT2 mapping
|
|
378
|
+
for rt2_variant in ["rt2", "retention_time2", "retentiontime2"]:
|
|
379
|
+
if rt2_variant in columns_lower:
|
|
380
|
+
mapping["rt2"] = columns[columns_lower.index(rt2_variant)]
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
# Database ID mapping
|
|
384
|
+
for db_id_variant in ["db_id", "database_id", "dbid"]:
|
|
385
|
+
if db_id_variant in columns_lower:
|
|
386
|
+
mapping["db_id"] = columns[columns_lower.index(db_id_variant)]
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
# Database mapping
|
|
390
|
+
for db_variant in ["db", "database"]:
|
|
391
|
+
if db_variant in columns_lower:
|
|
392
|
+
mapping["db"] = columns[columns_lower.index(db_variant)]
|
|
393
|
+
break
|
|
394
|
+
|
|
395
|
+
return mapping
|
|
396
|
+
|
|
397
|
+
def _safe_float_conversion(self, value: Any) -> Optional[float]:
|
|
398
|
+
"""
|
|
399
|
+
Safely convert a value to float, returning None if conversion fails.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
value: Value to convert
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
Float value or None
|
|
406
|
+
"""
|
|
407
|
+
if value is None or value == "":
|
|
408
|
+
return None
|
|
409
|
+
try:
|
|
410
|
+
return float(value)
|
|
411
|
+
except (ValueError, TypeError):
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def annotate_features(self,
|
|
415
|
+
features_df: Union[pl.DataFrame, "pd.DataFrame"],
|
|
416
|
+
mz_tolerance: float = 0.01,
|
|
417
|
+
rt_tolerance: Optional[float] = None) -> pl.DataFrame:
|
|
418
|
+
"""
|
|
419
|
+
Annotate features based on library matches using m/z and retention time.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
features_df: DataFrame containing features with 'mz' and optionally 'rt' columns
|
|
423
|
+
mz_tolerance: Mass tolerance in Da for matching
|
|
424
|
+
rt_tolerance: Retention time tolerance in minutes for matching (if None, RT not used)
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
DataFrame with annotation results
|
|
428
|
+
"""
|
|
429
|
+
if self.lib_df is None or len(self.lib_df) == 0:
|
|
430
|
+
raise ValueError("Library is empty. Import compounds first.")
|
|
431
|
+
|
|
432
|
+
# Convert pandas DataFrame to Polars if needed
|
|
433
|
+
if hasattr(features_df, 'to_pandas'): # It's already a Polars DataFrame
|
|
434
|
+
features_pl = features_df
|
|
435
|
+
elif hasattr(features_df, 'values'): # It's likely a pandas DataFrame
|
|
436
|
+
try:
|
|
437
|
+
import pandas as pd
|
|
438
|
+
if isinstance(features_df, pd.DataFrame):
|
|
439
|
+
features_pl = pl.from_pandas(features_df)
|
|
440
|
+
else:
|
|
441
|
+
features_pl = features_df
|
|
442
|
+
except ImportError:
|
|
443
|
+
features_pl = features_df
|
|
444
|
+
else:
|
|
445
|
+
features_pl = features_df
|
|
446
|
+
|
|
447
|
+
annotations = []
|
|
448
|
+
|
|
449
|
+
for feature_row in features_pl.iter_rows(named=True):
|
|
450
|
+
feature_mz = feature_row.get("mz")
|
|
451
|
+
feature_rt = feature_row.get("rt")
|
|
452
|
+
|
|
453
|
+
if feature_mz is None:
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Find matching library entries
|
|
457
|
+
mz_matches = self.lib_df.filter(
|
|
458
|
+
(pl.col("mz") >= feature_mz - mz_tolerance) &
|
|
459
|
+
(pl.col("mz") <= feature_mz + mz_tolerance)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Apply RT filter if both RT tolerance and feature RT are available
|
|
463
|
+
if rt_tolerance is not None and feature_rt is not None:
|
|
464
|
+
# Filter library entries that have RT values
|
|
465
|
+
rt_matches = mz_matches.filter(
|
|
466
|
+
pl.col("rt").is_not_null() &
|
|
467
|
+
(pl.col("rt") >= feature_rt - rt_tolerance) &
|
|
468
|
+
(pl.col("rt") <= feature_rt + rt_tolerance)
|
|
469
|
+
)
|
|
470
|
+
if len(rt_matches) > 0:
|
|
471
|
+
matches = rt_matches
|
|
472
|
+
else:
|
|
473
|
+
matches = mz_matches # Fall back to m/z-only matches
|
|
474
|
+
else:
|
|
475
|
+
matches = mz_matches
|
|
476
|
+
|
|
477
|
+
# Create annotation entries
|
|
478
|
+
for match_row in matches.iter_rows(named=True):
|
|
479
|
+
annotation = {
|
|
480
|
+
"feature_mz": feature_mz,
|
|
481
|
+
"feature_rt": feature_rt,
|
|
482
|
+
"lib_uid": match_row["lib_uid"],
|
|
483
|
+
"cmpd_uid": match_row.get("cmpd_uid"),
|
|
484
|
+
"source_id": match_row.get("source_id"),
|
|
485
|
+
"name": match_row["name"],
|
|
486
|
+
"formula": match_row["formula"],
|
|
487
|
+
"adduct": match_row["adduct"],
|
|
488
|
+
"smiles": match_row["smiles"],
|
|
489
|
+
"inchi": match_row["inchi"],
|
|
490
|
+
"inchikey": match_row["inchikey"],
|
|
491
|
+
"lib_mz": match_row["mz"],
|
|
492
|
+
"lib_rt": match_row["rt"],
|
|
493
|
+
"delta_mz": abs(feature_mz - match_row["mz"]),
|
|
494
|
+
"delta_rt": abs(feature_rt - match_row["rt"]) if feature_rt is not None and match_row["rt"] is not None else None,
|
|
495
|
+
}
|
|
496
|
+
annotations.append(annotation)
|
|
497
|
+
|
|
498
|
+
return pl.DataFrame(annotations) if annotations else pl.DataFrame()
|
|
499
|
+
|
|
500
|
+
def get_adducts_for_polarity(self, polarity: str) -> List[str]:
|
|
501
|
+
"""
|
|
502
|
+
Get list of supported adducts for a given polarity.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
polarity: "positive" or "negative"
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
List of adduct names
|
|
509
|
+
"""
|
|
510
|
+
return [
|
|
511
|
+
adduct for adduct, props in self.ADDUCT_DEFINITIONS.items()
|
|
512
|
+
if props["polarity"] == polarity.lower()
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
def __len__(self) -> int:
|
|
516
|
+
"""Return number of library entries."""
|
|
517
|
+
return len(self.lib_df) if self.lib_df is not None else 0
|
|
518
|
+
|
|
519
|
+
def _reload(self):
|
|
520
|
+
"""
|
|
521
|
+
Reloads all masster modules to pick up any changes to their source code,
|
|
522
|
+
and updates the instance's class reference to the newly reloaded class version.
|
|
523
|
+
This ensures that the instance uses the latest implementation without restarting the interpreter.
|
|
524
|
+
"""
|
|
525
|
+
import importlib
|
|
526
|
+
import sys
|
|
527
|
+
|
|
528
|
+
# Get the base module name (masster)
|
|
529
|
+
base_modname = self.__class__.__module__.split(".")[0]
|
|
530
|
+
current_module = self.__class__.__module__
|
|
531
|
+
|
|
532
|
+
# Dynamically find all lib submodules
|
|
533
|
+
lib_modules = []
|
|
534
|
+
lib_module_prefix = f"{base_modname}.lib."
|
|
535
|
+
|
|
536
|
+
# Get all currently loaded modules that are part of the lib package
|
|
537
|
+
for module_name in sys.modules:
|
|
538
|
+
if module_name.startswith(lib_module_prefix) and module_name != current_module:
|
|
539
|
+
lib_modules.append(module_name)
|
|
540
|
+
|
|
541
|
+
# Add core masster modules
|
|
542
|
+
core_modules = [
|
|
543
|
+
f"{base_modname}._version",
|
|
544
|
+
f"{base_modname}.chromatogram",
|
|
545
|
+
f"{base_modname}.spectrum",
|
|
546
|
+
f"{base_modname}.logger",
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
'''# Add study submodules (for cross-dependencies)
|
|
550
|
+
study_modules = []
|
|
551
|
+
study_module_prefix = f"{base_modname}.study."
|
|
552
|
+
for module_name in sys.modules:
|
|
553
|
+
if module_name.startswith(study_module_prefix) and module_name != current_module:
|
|
554
|
+
study_modules.append(module_name)'''
|
|
555
|
+
|
|
556
|
+
'''# Add sample submodules (for cross-dependencies)
|
|
557
|
+
sample_modules = []
|
|
558
|
+
sample_module_prefix = f"{base_modname}.sample."
|
|
559
|
+
for module_name in sys.modules:
|
|
560
|
+
if module_name.startswith(sample_module_prefix) and module_name != current_module:
|
|
561
|
+
sample_modules.append(module_name)'''
|
|
562
|
+
|
|
563
|
+
all_modules_to_reload = core_modules + lib_modules # sample_modules + study_modules +
|
|
564
|
+
|
|
565
|
+
# Reload all discovered modules
|
|
566
|
+
for full_module_name in all_modules_to_reload:
|
|
567
|
+
try:
|
|
568
|
+
if full_module_name in sys.modules:
|
|
569
|
+
mod = sys.modules[full_module_name]
|
|
570
|
+
importlib.reload(mod)
|
|
571
|
+
# Note: Lib class doesn't have a logger by default, so we just print or use warnings
|
|
572
|
+
#print(f"Reloaded module: {full_module_name}")
|
|
573
|
+
except Exception as e:
|
|
574
|
+
print(f"Warning: Failed to reload module {full_module_name}: {e}")
|
|
575
|
+
|
|
576
|
+
# Finally, reload the current module (lib.py)
|
|
577
|
+
try:
|
|
578
|
+
mod = __import__(current_module, fromlist=[current_module.split(".")[0]])
|
|
579
|
+
importlib.reload(mod)
|
|
580
|
+
|
|
581
|
+
# Get the updated class reference from the reloaded module
|
|
582
|
+
new = getattr(mod, self.__class__.__name__)
|
|
583
|
+
# Update the class reference of the instance
|
|
584
|
+
self.__class__ = new
|
|
585
|
+
|
|
586
|
+
print("Lib module reload completed")
|
|
587
|
+
except Exception as e:
|
|
588
|
+
print(f"Error: Failed to reload current module {current_module}: {e}")
|
|
589
|
+
|
|
590
|
+
def __str__(self) -> str:
|
|
591
|
+
"""String representation of the library."""
|
|
592
|
+
if self.lib_df is None or len(self.lib_df) == 0:
|
|
593
|
+
return "Empty Lib instance"
|
|
594
|
+
|
|
595
|
+
unique_compounds = self.lib_df.select("name").unique().height
|
|
596
|
+
unique_adducts = self.lib_df.select("adduct").unique().height
|
|
597
|
+
|
|
598
|
+
return f"Lib instance with {len(self)} entries ({unique_compounds} unique compounds, {unique_adducts} adduct types)"
|