Skip to content

Commit f8cd527

Browse files
committed
Merge branch '255_library_molecular_formula' into 'dev'
Separate MS1 Molecular Formula from library spectral formulas (#255) See merge request mass-spectrometry/corems!237
2 parents baaddd4 + 14e610d commit f8cd527

5 files changed

Lines changed: 108 additions & 20 deletions

File tree

corems/mass_spectra/factory/lc_class.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2400,8 +2400,11 @@ def feature_annotations_table(
24002400
- sample_id: sample ID
24012401
- Mass Feature ID: mass feature ID within the sample
24022402
- Mass feature attributes (mz, scan_time, intensity, etc.)
2403-
- MS1 annotations (if molecular_formula_search was run)
2404-
- MS2 annotations (if ms2_spectral_search was run)
2403+
- MS1 annotations (if molecular_formula_search was run):
2404+
``Molecular Formula``, ``Ion Formula``, ``Calculated m/z``, etc.
2405+
- MS2 annotations (if ms2_spectral_search was run):
2406+
``Library Molecular Formula``, ``Library Ion Formula``,
2407+
``Entropy Similarity``, ``name``, etc.
24052408
24062409
Notes
24072410
-----
@@ -2411,6 +2414,13 @@ def feature_annotations_table(
24112414
Only mass features that are loaded in each sample's mass_features dict
24122415
are included (typically the representative features if load_representatives
24132416
was used in process_consensus_features).
2417+
2418+
``Molecular Formula`` / ``Ion Formula`` are filled only from MS1 molecular
2419+
formula search. Spectral-library formulas appear under
2420+
``Library Molecular Formula`` / ``Library Ion Formula`` so MS2-only hits
2421+
are not mistaken for MS1 formula assignments. When MS1 and MS2 match
2422+
(same ion formula), the library formula columns are cleared on that
2423+
row to avoid repeating the MS1 values.
24142424
24152425
Raises
24162426
------
@@ -2504,8 +2514,10 @@ def feature_annotations_table(
25042514
'Isotopologue Similarity',
25052515
'Confidence Score',
25062516
'Ion Formula',
2507-
'Ion Type',
25082517
'Molecular Formula',
2518+
'Library Ion Formula',
2519+
'Library Ion Type',
2520+
'Library Molecular Formula',
25092521
'inchikey',
25102522
'name',
25112523
'ref_ms_id',

corems/mass_spectra/output/export.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1489,12 +1489,14 @@ def clean_ms1_report(self, ms1_summary_full):
14891489
self.get_isotope_type(f) for f in ms1_summary["ion_formula"].tolist()
14901490
]
14911491

1492-
# Reorder columns
1492+
# Keep MS1 neutral Molecular Formula so it is not confused with library formulas
1493+
# from MS2 spectral metadata.
14931494
ms1_summary = ms1_summary[
14941495
[
14951496
"mf_id",
14961497
"ion_formula",
14971498
"isotopologue_type",
1499+
"Molecular Formula",
14981500
"Calculated m/z",
14991501
"m/z Error (ppm)",
15001502
"m/z Error Score",
@@ -1580,16 +1582,20 @@ def clean_ms2_report(self, metabolite_summary):
15801582
The cleaned metabolomics summary DataFrame.
15811583
"""
15821584
metabolite_summary = metabolite_summary.reset_index()
1583-
metabolite_summary["ion_formula"] = [
1585+
# Library (MS2 metadata) formulas — keep separate from MS1 search fields
1586+
metabolite_summary["library_ion_formula"] = [
15841587
self.get_ion_formula(f, a)
15851588
for f, a in zip(metabolite_summary["formula"], metabolite_summary["ref_ion_type"])
15861589
]
1590+
metabolite_summary = metabolite_summary.rename(
1591+
columns={"formula": "library_formula"}
1592+
)
15871593

15881594
col_order = [
15891595
"mf_id",
1590-
"ion_formula",
1596+
"library_ion_formula",
15911597
"ref_ion_type",
1592-
"formula",
1598+
"library_formula",
15931599
"inchikey",
15941600
"name",
15951601
"inchi",
@@ -1631,6 +1637,14 @@ def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
16311637
The MS1 annotation report DataFrame.
16321638
ms2_annot_report : DataFrame
16331639
The MS2 annotation report DataFrame.
1640+
1641+
Notes
1642+
-----
1643+
``Molecular Formula`` / ``Ion Formula`` come only from MS1 molecular
1644+
formula search. MS2 spectral library formulas are exposed as
1645+
``Library Molecular Formula`` / ``Library Ion Formula``.
1646+
When MS1 and MS2 match (same ion formula), the library formula columns
1647+
are cleared so they do not repeat the MS1 values on that row.
16341648
"""
16351649
# If there is an ms1_annot_report, merge it with the mf_report
16361650
if ms1_annot_report is not None and not ms1_annot_report.empty:
@@ -1642,24 +1656,31 @@ def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
16421656
on=["mf_id", "isotopologue_type"],
16431657
)
16441658
if ms2_annot_report is not None:
1645-
# If both reports contain 'ion_formula', prefer a merge that respects it.
1646-
# Otherwise fall back to merging on 'mf_id' only to remain robust when
1647-
# MS1 formula assignment wasn't performed or MS2 summary lacks the field.
1648-
if "ion_formula" in mf_report.columns and "ion_formula" in ms2_annot_report.columns:
1659+
# Prefer joining MS2 hits to matching MS1 ion formula when both exist.
1660+
# MS2 uses library_ion_formula so MS1 Ion Formula is not overwritten.
1661+
if (
1662+
"ion_formula" in mf_report.columns
1663+
and "library_ion_formula" in ms2_annot_report.columns
1664+
):
16491665
# pull out the records without ion_formula and merge on mf_id only
16501666
mf_no_ion_formula = mf_report[mf_report["ion_formula"].isna()]
1651-
mf_no_ion_formula = mf_no_ion_formula.drop(columns=["ion_formula"]) if "ion_formula" in mf_no_ion_formula.columns else mf_no_ion_formula
1667+
mf_no_ion_formula = (
1668+
mf_no_ion_formula.drop(columns=["ion_formula"])
1669+
if "ion_formula" in mf_no_ion_formula.columns
1670+
else mf_no_ion_formula
1671+
)
16521672
mf_no_ion_formula = pd.merge(
16531673
mf_no_ion_formula, ms2_annot_report, how="left", on=["mf_id"]
16541674
)
16551675

1656-
# pull out the records with ion_formula and merge on mf_id + ion_formula
1676+
# pull out the records with ion_formula and merge on mf_id + formula match
16571677
mf_with_ion_formula = mf_report[~mf_report["ion_formula"].isna()]
16581678
mf_with_ion_formula = pd.merge(
16591679
mf_with_ion_formula,
16601680
ms2_annot_report,
16611681
how="left",
1662-
on=["mf_id", "ion_formula"],
1682+
left_on=["mf_id", "ion_formula"],
1683+
right_on=["mf_id", "library_ion_formula"],
16631684
)
16641685

16651686
# put back together
@@ -1688,8 +1709,9 @@ def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
16881709
"mass_spectrum_deconvoluted_parent": "Is Largest Ion after Deconvolution",
16891710
"associated_mass_features": "Associated Mass Features after Deconvolution",
16901711
"ion_formula": "Ion Formula",
1691-
"formula": "Molecular Formula",
1692-
"ref_ion_type": "Ion Type",
1712+
"library_ion_formula": "Library Ion Formula",
1713+
"library_formula": "Library Molecular Formula",
1714+
"ref_ion_type": "Library Ion Type",
16931715
"annot_level": "Lipid Annotation Level",
16941716
"lipid_molecular_species_id": "Lipid Molecular Species",
16951717
"lipid_summed_name": "Lipid Species",
@@ -1701,6 +1723,35 @@ def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
17011723
"n_spectra_contributing": "Spectra with Annotation (n)",
17021724
}
17031725
mf_report = mf_report.rename(columns=rename_dict)
1726+
1727+
# When MS1 and MS2 co-annotate the same ion/molecular formula, library formula
1728+
# columns only repeat MS1 fields — clear them so the row is not redundant.
1729+
# Keep Library * columns populated for MS2-only (or mismatched) hits.
1730+
if (
1731+
"Ion Formula" in mf_report.columns
1732+
and "Library Ion Formula" in mf_report.columns
1733+
):
1734+
ion_match = (
1735+
mf_report["Ion Formula"].notna()
1736+
& mf_report["Library Ion Formula"].notna()
1737+
& (mf_report["Ion Formula"] == mf_report["Library Ion Formula"])
1738+
)
1739+
mf_report.loc[ion_match, "Library Ion Formula"] = pd.NA
1740+
if "Library Molecular Formula" in mf_report.columns:
1741+
mf_report.loc[ion_match, "Library Molecular Formula"] = pd.NA
1742+
elif (
1743+
"Molecular Formula" in mf_report.columns
1744+
and "Library Molecular Formula" in mf_report.columns
1745+
):
1746+
mol_match = (
1747+
mf_report["Molecular Formula"].notna()
1748+
& mf_report["Library Molecular Formula"].notna()
1749+
& (mf_report["Molecular Formula"] == mf_report["Library Molecular Formula"])
1750+
)
1751+
mf_report.loc[mol_match, "Library Molecular Formula"] = pd.NA
1752+
if "Library Ion Formula" in mf_report.columns:
1753+
mf_report.loc[mol_match, "Library Ion Formula"] = pd.NA
1754+
17041755
mf_report["Sample Name"] = self.mass_spectra.sample_name
17051756
mf_report["Polarity"] = self.mass_spectra.polarity
17061757
mf_report = mf_report[
@@ -2065,18 +2116,20 @@ def clean_ms2_report(self, lipid_summary):
20652116
The cleaned lipid summary DataFrame.
20662117
"""
20672118
lipid_summary = lipid_summary.reset_index()
2068-
lipid_summary["ion_formula"] = [
2119+
# Library (MS2 metadata) formulas — keep separate from MS1 search fields
2120+
lipid_summary["library_ion_formula"] = [
20692121
self.get_ion_formula(f, a)
20702122
for f, a in zip(lipid_summary["formula"], lipid_summary["ref_ion_type"])
20712123
]
2124+
lipid_summary = lipid_summary.rename(columns={"formula": "library_formula"})
20722125

20732126
# Reorder columns
20742127
lipid_summary = lipid_summary[
20752128
[
20762129
"mf_id",
2077-
"ion_formula",
2130+
"library_ion_formula",
20782131
"ref_ion_type",
2079-
"formula",
2132+
"library_formula",
20802133
"annot_level",
20812134
"lipid_molecular_species_id",
20822135
"lipid_summed_name",

tests/test_lcms_collection.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,16 @@ def test_lcms_collection_feature_annotations_table(lcms_collection, msp_file_loc
530530
if 'Entropy Similarity' in annotations_table.columns:
531531
matched_features = annotations_table[annotations_table['Entropy Similarity'].notna()]
532532
assert len(matched_features) > 0, "Should have at least some MS2 spectral matches after search"
533+
# Library formula must not be written as MS1 Molecular Formula
534+
assert 'Library Molecular Formula' in annotations_table.columns
535+
lib_hits = matched_features[
536+
matched_features['Library Molecular Formula'].notna()
537+
] if 'Library Molecular Formula' in matched_features.columns else matched_features.iloc[0:0]
538+
if len(lib_hits) > 0 and 'Molecular Formula' in annotations_table.columns:
539+
# Rows with library formula only (no MS1 search here) should leave MS1 Molecular Formula empty
540+
assert lib_hits['Molecular Formula'].isna().all() or (
541+
lib_hits['Molecular Formula'].fillna('').eq('').all()
542+
)
533543
else:
534544
# If column doesn't exist, the test should fail
535545
raise AssertionError("Expected 'Entropy Similarity' column in annotations table after MS2 spectral search")

tests/test_lcms_metabolomics.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import warnings
44

55
import numpy as np
6+
import pandas as pd
67
import pytest
78

89
from corems.mass_spectra.output.export import LCMSMetabolomicsExport
@@ -131,7 +132,15 @@ def test_lcms_metabolomics(tmp_path, postgres_database, lcms_obj, msp_file_locat
131132
exporter.to_hdf(overwrite=True)
132133
exporter.report_to_csv(molecular_metadata=metabolite_metadata_negative)
133134
report = exporter.to_report(molecular_metadata=metabolite_metadata_negative)
134-
assert report['Ion Formula'][1] == 'C24 H47 O2'
135+
# MS2 spectral library formula must not fill MS1 "Molecular Formula" / "Ion Formula";
136+
# library values live under Library * columns.
137+
assert report["Library Ion Formula"][1] == "C24 H47 O2"
138+
assert "Library Molecular Formula" in report.columns
139+
if "Ion Formula" in report.columns:
140+
# No MS1 formula search in this test — MS1 ion formula should be empty
141+
assert pd.isna(report["Ion Formula"][1]) or report["Ion Formula"][1] is None
142+
if "Molecular Formula" in report.columns:
143+
assert pd.isna(report["Molecular Formula"][1]) or report["Molecular Formula"][1] is None
135144
assert report['chebi'][1] == 28866
136145

137146
# Test plotting mass feature with MS2 mirror plot

tests/test_wf_lipidomics.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import shutil
55
import numpy as np
6+
import pandas as pd
67
import pytest
78

89
from corems.encapsulation.constant import Labels
@@ -238,7 +239,10 @@ def test_lipidomics_workflow(tmp_path, postgres_database, lcms_obj, lipidomics_s
238239
exporter.to_hdf(overwrite=True)
239240
exporter.report_to_csv(molecular_metadata=lipid_metadata)
240241
report = exporter.to_report(molecular_metadata=lipid_metadata)
242+
# This workflow runs MS1 formula search + MS2 library match. When ion formulas
243+
# agree, Library Ion Formula is cleared and the value lives in Ion Formula.
241244
assert report['Ion Formula'][1] == 'C24 H47 O2'
245+
assert pd.isna(report['Library Ion Formula'][1])
242246
assert report['Lipid Molecular Species'][0] == 'FA 20:5'
243247

244248
# Import the hdf5 file, assert that its df is same as above and that we can plot a mass feature

0 commit comments

Comments
 (0)