msfiddle 2.0.1__tar.gz → 2.1.0__tar.gz

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.
Files changed (41) hide show
  1. {msfiddle-2.0.1 → msfiddle-2.1.0}/PKG-INFO +45 -11
  2. {msfiddle-2.0.1 → msfiddle-2.1.0}/README.md +44 -10
  3. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/__init__.py +6 -1
  4. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/api.py +17 -4
  5. msfiddle-2.1.0/msfiddle/external_results.py +380 -0
  6. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/main.py +17 -2
  7. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/PKG-INFO +45 -11
  8. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/SOURCES.txt +2 -0
  9. {msfiddle-2.0.1 → msfiddle-2.1.0}/setup.py +1 -1
  10. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_api.py +101 -1
  11. msfiddle-2.1.0/tests/test_external_results.py +282 -0
  12. {msfiddle-2.0.1 → msfiddle-2.1.0}/LICENSE +0 -0
  13. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_casmi.yml +0 -0
  14. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_demo.yml +0 -0
  15. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_demo_wo_da.yml +0 -0
  16. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_embl.yml +0 -0
  17. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_orbitrap.yml +0 -0
  18. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/config/fiddle_tcn_qtof.yml +0 -0
  19. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/dataset.py +0 -0
  20. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/demo/input_msms.mgf +0 -0
  21. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/demo/output_buddy.csv +0 -0
  22. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/demo/output_fiddle.csv +0 -0
  23. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/demo/output_fiddle_all.csv +0 -0
  24. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/demo/output_sirius.csv +0 -0
  25. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/download.py +0 -0
  26. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/model_tcn.py +0 -0
  27. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/utils/__init__.py +0 -0
  28. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/utils/mol_utils.py +0 -0
  29. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/utils/msms_utils.py +0 -0
  30. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/utils/pkl_utils.py +0 -0
  31. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle/utils/refine_utils.py +0 -0
  32. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/dependency_links.txt +0 -0
  33. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/entry_points.txt +0 -0
  34. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/requires.txt +0 -0
  35. {msfiddle-2.0.1 → msfiddle-2.1.0}/msfiddle.egg-info/top_level.txt +0 -0
  36. {msfiddle-2.0.1 → msfiddle-2.1.0}/setup.cfg +0 -0
  37. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_download.py +0 -0
  38. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_main.py +0 -0
  39. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_mol_utils.py +0 -0
  40. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_msms_utils.py +0 -0
  41. {msfiddle-2.0.1 → msfiddle-2.1.0}/tests/test_refine_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msfiddle
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: A package for predicting chemical formulas from tandem mass spectra
5
5
  Home-page: https://github.com/JosieHong/msfiddle
6
6
  Author: Yuhui Hong
@@ -64,17 +64,13 @@ For the full experimental codebase, see https://github.com/JosieHong/FIDDLE.
64
64
  ## Installation
65
65
 
66
66
  ```bash
67
- pip install msfiddle
67
+ pip install msfiddle # base install, no PyTorch
68
+ pip install "msfiddle[inference]" # base install + PyTorch (needed for predictions and the CLI)
68
69
  ```
69
70
 
70
- PyTorch is required for inference. Install the optional inference extra,
71
- or install PyTorch separately for your platform:
72
- ```bash
73
- pip install "msfiddle[inference]"
74
- ```
75
-
76
- See the official PyTorch installation guide for custom CUDA builds:
77
- https://pytorch.org/get-started/locally/.
71
+ For a custom PyTorch build (CUDA, ROCm, Apple Silicon, etc.), install torch
72
+ first via the [official guide](https://pytorch.org/get-started/locally/), then
73
+ `pip install msfiddle`.
78
74
 
79
75
  ## Usage
80
76
 
@@ -90,7 +86,7 @@ msfiddle-download-models --destination /path/to/models \
90
86
  --models fiddle_tcn_qtof fiddle_rescore_qtof
91
87
  ```
92
88
 
93
- `msfiddle` 2.0.1 reuses the FIDDLE `v2.0.0` checkpoint assets.
89
+ All `msfiddle` 2.x releases reuse the FIDDLE `v2.0.0` checkpoint assets.
94
90
 
95
91
  Run the packaged demo:
96
92
  ```bash
@@ -190,6 +186,44 @@ The Python `predict_from_spectrum()` API returns a list of candidate dictionarie
190
186
  `predict_batch()` returns one record per input spectrum with `id`, `candidates`,
191
187
  and `metadata`.
192
188
 
189
+ ### Native/original BUDDY and SIRIUS outputs
190
+
191
+ Use the same MGF input file to generate native
192
+ [BUDDY/msbuddy](https://msbuddy.readthedocs.io/en/latest/cmdapi.html) and
193
+ [SIRIUS](https://v6.docs.sirius-ms.io/cli/) outputs. `msfiddle` accepts these
194
+ native/original files directly through `--buddy_path` and `--sirius_path`. The
195
+ older msfiddle-normalized CSV formats documented in `docs/formats.rst` are
196
+ **deprecated** and will be removed in `msfiddle` 3.0.0; loading them emits a
197
+ `DeprecationWarning`.
198
+
199
+ ```bash
200
+ msbuddy -mgf /path/to/data.mgf \
201
+ -output /path/to/buddy_output \
202
+ -ms orbitrap \
203
+ -p -n_cpu 12 \
204
+ -d -hal
205
+ ```
206
+
207
+ `msbuddy` writes `msbuddy_result_summary.tsv`; `-d` also writes detailed
208
+ per-query `formula_results.tsv` files. Pass either the summary file or the
209
+ output directory to `--buddy_path`. The full output directory is preferred
210
+ because it includes per-candidate FDR scores for ranks beyond rank 1.
211
+
212
+ ```bash
213
+ sirius --input /path/to/data.mgf \
214
+ --project /path/to/sirius_project \
215
+ formulas --profile orbitrap
216
+
217
+ sirius --project /path/to/sirius_project \
218
+ summaries --top-k-summary=5 \
219
+ --output /path/to/sirius_output
220
+ ```
221
+
222
+ SIRIUS writes a project space and exports summary files such as
223
+ `formula_identifications.tsv`. Pass either a formula-identification summary file
224
+ or the summary output directory to `--sirius_path`.
225
+ SIRIUS 6 may require `sirius login` before formula computation.
226
+
193
227
  ### MGF input
194
228
 
195
229
  The required MGF fields are `TITLE`, `PRECURSOR_MZ`, `PRECURSOR_TYPE`, and
@@ -23,17 +23,13 @@ For the full experimental codebase, see https://github.com/JosieHong/FIDDLE.
23
23
  ## Installation
24
24
 
25
25
  ```bash
26
- pip install msfiddle
26
+ pip install msfiddle # base install, no PyTorch
27
+ pip install "msfiddle[inference]" # base install + PyTorch (needed for predictions and the CLI)
27
28
  ```
28
29
 
29
- PyTorch is required for inference. Install the optional inference extra,
30
- or install PyTorch separately for your platform:
31
- ```bash
32
- pip install "msfiddle[inference]"
33
- ```
34
-
35
- See the official PyTorch installation guide for custom CUDA builds:
36
- https://pytorch.org/get-started/locally/.
30
+ For a custom PyTorch build (CUDA, ROCm, Apple Silicon, etc.), install torch
31
+ first via the [official guide](https://pytorch.org/get-started/locally/), then
32
+ `pip install msfiddle`.
37
33
 
38
34
  ## Usage
39
35
 
@@ -49,7 +45,7 @@ msfiddle-download-models --destination /path/to/models \
49
45
  --models fiddle_tcn_qtof fiddle_rescore_qtof
50
46
  ```
51
47
 
52
- `msfiddle` 2.0.1 reuses the FIDDLE `v2.0.0` checkpoint assets.
48
+ All `msfiddle` 2.x releases reuse the FIDDLE `v2.0.0` checkpoint assets.
53
49
 
54
50
  Run the packaged demo:
55
51
  ```bash
@@ -149,6 +145,44 @@ The Python `predict_from_spectrum()` API returns a list of candidate dictionarie
149
145
  `predict_batch()` returns one record per input spectrum with `id`, `candidates`,
150
146
  and `metadata`.
151
147
 
148
+ ### Native/original BUDDY and SIRIUS outputs
149
+
150
+ Use the same MGF input file to generate native
151
+ [BUDDY/msbuddy](https://msbuddy.readthedocs.io/en/latest/cmdapi.html) and
152
+ [SIRIUS](https://v6.docs.sirius-ms.io/cli/) outputs. `msfiddle` accepts these
153
+ native/original files directly through `--buddy_path` and `--sirius_path`. The
154
+ older msfiddle-normalized CSV formats documented in `docs/formats.rst` are
155
+ **deprecated** and will be removed in `msfiddle` 3.0.0; loading them emits a
156
+ `DeprecationWarning`.
157
+
158
+ ```bash
159
+ msbuddy -mgf /path/to/data.mgf \
160
+ -output /path/to/buddy_output \
161
+ -ms orbitrap \
162
+ -p -n_cpu 12 \
163
+ -d -hal
164
+ ```
165
+
166
+ `msbuddy` writes `msbuddy_result_summary.tsv`; `-d` also writes detailed
167
+ per-query `formula_results.tsv` files. Pass either the summary file or the
168
+ output directory to `--buddy_path`. The full output directory is preferred
169
+ because it includes per-candidate FDR scores for ranks beyond rank 1.
170
+
171
+ ```bash
172
+ sirius --input /path/to/data.mgf \
173
+ --project /path/to/sirius_project \
174
+ formulas --profile orbitrap
175
+
176
+ sirius --project /path/to/sirius_project \
177
+ summaries --top-k-summary=5 \
178
+ --output /path/to/sirius_output
179
+ ```
180
+
181
+ SIRIUS writes a project space and exports summary files such as
182
+ `formula_identifications.tsv`. Pass either a formula-identification summary file
183
+ or the summary output directory to `--sirius_path`.
184
+ SIRIUS 6 may require `sirius login` before formula computation.
185
+
152
186
  ### MGF input
153
187
 
154
188
  The required MGF fields are `TITLE`, `PRECURSOR_MZ`, `PRECURSOR_TYPE`, and
@@ -2,7 +2,12 @@
2
2
  msfiddle: A package for predicting chemical formulas from tandem mass spectra
3
3
  """
4
4
 
5
- __version__ = "2.0.1"
5
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
6
+
7
+ try:
8
+ __version__ = _pkg_version("msfiddle")
9
+ except PackageNotFoundError: # running from a source checkout without install
10
+ __version__ = "0.0.0+unknown"
6
11
 
7
12
  from .api import (
8
13
  MsFiddlePredictor,
@@ -20,6 +20,7 @@ from pyteomics import mgf
20
20
 
21
21
  from .download import download_models as download_pretrained_models
22
22
  from .download import get_checkpoint_dir
23
+ from .external_results import load_buddy_results, load_sirius_results
23
24
  from .utils.mol_utils import (
24
25
  ATOMS_INDEX,
25
26
  formula_to_dict,
@@ -198,6 +199,10 @@ class MsFiddlePredictor:
198
199
  ) -> pd.DataFrame:
199
200
  """Predict formulas for spectra in an MGF file.
200
201
 
202
+ ``buddy_path`` and ``sirius_path`` may point to either native/original
203
+ BUDDY/msbuddy and SIRIUS formula-identification outputs or legacy
204
+ msfiddle-normalized CSV files.
205
+
201
206
  The returned DataFrame uses the same result columns as the CLI CSV.
202
207
  """
203
208
 
@@ -212,8 +217,8 @@ class MsFiddlePredictor:
212
217
  encoded = self._load_mgf_spectra(test_data_path)
213
218
  predictions = self._predict_encoded(encoded, batch_size=batch_size)
214
219
 
215
- buddy_df = pd.read_csv(buddy_path) if buddy_path else None
216
- sirius_df = pd.read_csv(sirius_path) if sirius_path else None
220
+ buddy_df = load_buddy_results(buddy_path) if buddy_path else None
221
+ sirius_df = load_sirius_results(sirius_path) if sirius_path else None
217
222
 
218
223
  refined_formula = {f"Refined Formula ({k})": [] for k in range(top_k)}
219
224
  refined_mass = {f"Refined Mass ({k})": [] for k in range(top_k)}
@@ -787,7 +792,8 @@ class MsFiddlePredictor:
787
792
  candidates.extend(
788
793
  formula
789
794
  for formula, score in zip(formulas, scores)
790
- if str(formula) != "nan"
795
+ if _is_external_formula(formula)
796
+ and pd.notna(score)
791
797
  and score < self.config["post_processing"]["buddy_fdr_thr"]
792
798
  )
793
799
  if sirius_df is not None:
@@ -815,7 +821,8 @@ class MsFiddlePredictor:
815
821
  candidates.extend(
816
822
  formula
817
823
  for formula, score in zip(formulas, scores)
818
- if str(formula) != "nan"
824
+ if _is_external_formula(formula)
825
+ and pd.notna(score)
819
826
  and score > self.config["post_processing"]["sirius_score_thr"]
820
827
  )
821
828
  return candidates
@@ -934,6 +941,12 @@ def _normalize_instrument_type(instrument_type: str) -> str:
934
941
  return normalized
935
942
 
936
943
 
944
+ def _is_external_formula(value: object) -> bool:
945
+ if pd.isna(value):
946
+ return False
947
+ return str(value).strip().lower() not in {"", "na", "nan", "none", "null", "<na>"}
948
+
949
+
937
950
  def _load_config(config_path: Path) -> dict[str, Any]:
938
951
  if not config_path.exists():
939
952
  raise FileNotFoundError(f"Config file not found: {config_path}")
@@ -0,0 +1,380 @@
1
+ """Load external formula candidates from BUDDY/msbuddy and SIRIUS outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import warnings
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ import pandas as pd
11
+
12
+ _NORMALIZED_CSV_DEPRECATION = (
13
+ "The msfiddle-normalized {tool} CSV format is deprecated and will be "
14
+ "removed in msfiddle 3.0.0. Pass the native/original {tool} output "
15
+ "(directory or summary file) instead."
16
+ )
17
+
18
+ BUDDY_FORMULA_COLUMNS = tuple(f"Pred Formula ({idx})" for idx in range(1, 6))
19
+ BUDDY_SCORE_COLUMNS = tuple(f"BUDDY Score ({idx})" for idx in range(1, 6))
20
+ BUDDY_COLUMNS = ("ID", "Adduct", *BUDDY_FORMULA_COLUMNS, *BUDDY_SCORE_COLUMNS)
21
+
22
+ SIRIUS_FORMULA_COLUMNS = tuple(f"Pred Formula ({idx})" for idx in range(1, 6))
23
+ SIRIUS_ADDUCT_COLUMNS = tuple(f"Pred Adduct ({idx})" for idx in range(1, 6))
24
+ SIRIUS_SCORE_COLUMNS = tuple(f"SIRIUS Score ({idx})" for idx in range(1, 6))
25
+ SIRIUS_COLUMNS = (
26
+ "ID",
27
+ *(
28
+ value
29
+ for idx in range(1, 6)
30
+ for value in (
31
+ f"Pred Formula ({idx})",
32
+ f"Pred Adduct ({idx})",
33
+ f"SIRIUS Score ({idx})",
34
+ )
35
+ ),
36
+ )
37
+
38
+ _BUDDY_NATIVE_COLUMNS = (
39
+ "identifier",
40
+ "adduct",
41
+ "formula_rank_1",
42
+ "estimated_fdr",
43
+ )
44
+ _SIRIUS_FORMULA_FILES = (
45
+ "formula_identifications_top-",
46
+ "formula_identifications_all",
47
+ "formula_identifications_adducts_top-",
48
+ "formula_identifications_adducts_all",
49
+ "formula_identifications",
50
+ )
51
+ _SIRIUS_ID_COLUMNS = (
52
+ "name",
53
+ "mappingFeatureId",
54
+ "alignedFeatureId",
55
+ "featureId",
56
+ "compoundId",
57
+ "id",
58
+ )
59
+ _SIRIUS_FORMULA_COLUMNS = ("molecularFormula", "formula", "Formula")
60
+ _SIRIUS_ADDUCT_COLUMNS = ("adduct", "ionType", "ion", "precursorIonType")
61
+ _SIRIUS_SCORE_COLUMNS = (
62
+ "SiriusScore",
63
+ "SIRIUS Score",
64
+ "rankingScore",
65
+ "ZodiacScore",
66
+ "ConfidenceScore",
67
+ "score",
68
+ )
69
+ _SIRIUS_RANK_COLUMNS = ("rank", "formulaRank")
70
+
71
+
72
+ def load_buddy_results(path: str | Path) -> pd.DataFrame:
73
+ """Load native/original or msfiddle-normalized BUDDY/msbuddy results.
74
+
75
+ Native/original msbuddy inputs may be either the
76
+ ``msbuddy_result_summary.tsv`` file or the output directory that contains
77
+ it. If detailed ``formula_results.tsv`` files are present, their
78
+ per-candidate FDR values are used.
79
+ """
80
+
81
+ resolved = _resolve_path(path, "BUDDY")
82
+ if resolved.is_dir():
83
+ return _load_msbuddy_directory(resolved)
84
+
85
+ df = _read_table(resolved)
86
+ if _has_columns(df, BUDDY_COLUMNS):
87
+ warnings.warn(
88
+ _NORMALIZED_CSV_DEPRECATION.format(tool="BUDDY"),
89
+ DeprecationWarning,
90
+ stacklevel=2,
91
+ )
92
+ return _normalize_buddy(df)
93
+ if _has_columns(df, _BUDDY_NATIVE_COLUMNS):
94
+ return _normalize_msbuddy_summary(df)
95
+ raise ValueError(
96
+ "Unsupported BUDDY result format. Expected a native/original msbuddy "
97
+ "summary or output directory, or an msfiddle-normalized CSV. Native "
98
+ "summary columns: "
99
+ f"{', '.join(_BUDDY_NATIVE_COLUMNS)}"
100
+ )
101
+
102
+
103
+ def load_sirius_results(path: str | Path) -> pd.DataFrame:
104
+ """Load native/original or msfiddle-normalized SIRIUS results."""
105
+
106
+ resolved = _resolve_path(path, "SIRIUS")
107
+ if resolved.is_dir():
108
+ resolved = _find_sirius_formula_file(resolved)
109
+
110
+ df = _read_table(resolved)
111
+ if _has_columns(df, SIRIUS_COLUMNS):
112
+ warnings.warn(
113
+ _NORMALIZED_CSV_DEPRECATION.format(tool="SIRIUS"),
114
+ DeprecationWarning,
115
+ stacklevel=2,
116
+ )
117
+ return _normalize_sirius(df)
118
+ return _normalize_sirius_native(df)
119
+
120
+
121
+ def _resolve_path(path: str | Path, label: str) -> Path:
122
+ resolved = Path(path)
123
+ if not resolved.exists():
124
+ raise FileNotFoundError(f"{label} result path not found: {resolved}")
125
+ return resolved
126
+
127
+
128
+ def _read_table(path: Path) -> pd.DataFrame:
129
+ suffix = path.suffix.lower()
130
+ if suffix in {".xlsx", ".xls"}:
131
+ return _clean_columns(pd.read_excel(path))
132
+ if suffix == ".tsv":
133
+ return _clean_columns(pd.read_csv(path, sep="\t"))
134
+ if suffix == ".csv":
135
+ return _clean_columns(pd.read_csv(path))
136
+ return _clean_columns(pd.read_csv(path, sep=None, engine="python"))
137
+
138
+
139
+ def _clean_columns(df: pd.DataFrame) -> pd.DataFrame:
140
+ df = df.copy()
141
+ df.columns = [str(column).strip().lstrip("\ufeff") for column in df.columns]
142
+ return df
143
+
144
+
145
+ def _has_columns(df: pd.DataFrame, columns: Iterable[str]) -> bool:
146
+ df_columns = set(df.columns)
147
+ return all(column in df_columns for column in columns)
148
+
149
+
150
+ def _normalize_buddy(df: pd.DataFrame) -> pd.DataFrame:
151
+ normalized = df.loc[:, BUDDY_COLUMNS].copy()
152
+ normalized["ID"] = normalized["ID"].map(_clean_identifier)
153
+ normalized["Adduct"] = normalized["Adduct"].map(_clean_text)
154
+ for column in BUDDY_FORMULA_COLUMNS:
155
+ normalized[column] = normalized[column].map(_clean_formula)
156
+ for column in BUDDY_SCORE_COLUMNS:
157
+ normalized[column] = pd.to_numeric(normalized[column], errors="coerce")
158
+ return normalized.dropna(subset=["ID"]).reset_index(drop=True)
159
+
160
+
161
+ def _load_msbuddy_directory(path: Path) -> pd.DataFrame:
162
+ summary_path = path / "msbuddy_result_summary.tsv"
163
+ if not summary_path.exists():
164
+ raise FileNotFoundError(
165
+ "Native msbuddy output directory must contain "
166
+ f"msbuddy_result_summary.tsv: {path}"
167
+ )
168
+ summary = _read_table(summary_path)
169
+ details = _load_msbuddy_details(path, summary)
170
+ return _normalize_msbuddy_summary(summary, details=details)
171
+
172
+
173
+ def _normalize_msbuddy_summary(
174
+ df: pd.DataFrame,
175
+ *,
176
+ details: dict[str, list[tuple[str, float]]] | None = None,
177
+ ) -> pd.DataFrame:
178
+ if not _has_columns(df, _BUDDY_NATIVE_COLUMNS):
179
+ raise ValueError(
180
+ "Native msbuddy summary is missing required columns: "
181
+ f"{', '.join(_BUDDY_NATIVE_COLUMNS)}"
182
+ )
183
+
184
+ rows: list[dict[str, object]] = []
185
+ for _, row in df.iterrows():
186
+ spec_id = _clean_identifier(row["identifier"])
187
+ out: dict[str, object] = {
188
+ "ID": spec_id,
189
+ "Adduct": _clean_text(row.get("adduct", pd.NA)),
190
+ }
191
+
192
+ detail_candidates = details.get(spec_id, []) if details else []
193
+ if detail_candidates:
194
+ for idx, (formula, score) in enumerate(detail_candidates[:5], start=1):
195
+ out[f"Pred Formula ({idx})"] = _clean_formula(formula)
196
+ out[f"BUDDY Score ({idx})"] = score
197
+ else:
198
+ for idx in range(1, 6):
199
+ out[f"Pred Formula ({idx})"] = _clean_formula(
200
+ row.get(f"formula_rank_{idx}", pd.NA)
201
+ )
202
+ # msbuddy summary exposes estimated FDR for rank 1 only.
203
+ out[f"BUDDY Score ({idx})"] = (
204
+ _to_number(row.get("estimated_fdr", pd.NA)) if idx == 1 else pd.NA
205
+ )
206
+ rows.append(out)
207
+ return _normalize_buddy(pd.DataFrame(rows, columns=BUDDY_COLUMNS))
208
+
209
+
210
+ def _load_msbuddy_details(
211
+ path: Path, summary: pd.DataFrame
212
+ ) -> dict[str, list[tuple[str, float]]]:
213
+ # Detail directories follow `{sanitized_id}_mz_{mz}_rt_{rt}`. Index by the
214
+ # sanitized id so we don't depend on how msbuddy formats mz.
215
+ by_sanitized_id: dict[str, str] = {}
216
+ for _, row in summary.iterrows():
217
+ spec_id = _clean_identifier(row["identifier"])
218
+ if pd.isna(spec_id):
219
+ continue
220
+ by_sanitized_id[_sanitize_msbuddy_identifier(spec_id)] = spec_id
221
+
222
+ details: dict[str, list[tuple[str, float]]] = {}
223
+ for result_path in path.glob("**/formula_results.tsv"):
224
+ folder = result_path.parent.name
225
+ sanitized_id, _, _ = folder.partition("_mz_")
226
+ if not sanitized_id:
227
+ continue
228
+ spec_id = by_sanitized_id.get(sanitized_id)
229
+ if spec_id is None:
230
+ continue
231
+ df = _read_table(result_path)
232
+ if not _has_columns(df, ("rank", "formula", "estimated_fdr")):
233
+ continue
234
+ df = df.copy()
235
+ df["rank"] = pd.to_numeric(df["rank"], errors="coerce")
236
+ df["estimated_fdr"] = pd.to_numeric(df["estimated_fdr"], errors="coerce")
237
+ df = df.sort_values("rank", na_position="last")
238
+ candidates: list[tuple[str, float]] = []
239
+ for _, row in df.iterrows():
240
+ formula = _clean_formula(row.get("formula", pd.NA))
241
+ if pd.isna(formula):
242
+ continue
243
+ candidates.append((str(formula), row.get("estimated_fdr", pd.NA)))
244
+ if len(candidates) == 5:
245
+ break
246
+ if candidates:
247
+ details[spec_id] = candidates
248
+ return details
249
+
250
+
251
+ def _sanitize_msbuddy_identifier(spec_id: str) -> str:
252
+ return spec_id.replace("/", "").replace(":", "").replace(" ", "").strip()
253
+
254
+
255
+ def _find_sirius_formula_file(path: Path) -> Path:
256
+ candidates = [
257
+ file_path
258
+ for file_path in path.glob("**/*")
259
+ if file_path.is_file()
260
+ and file_path.suffix.lower() in {".tsv", ".csv", ".xlsx", ".xls"}
261
+ and file_path.stem.startswith("formula_identifications")
262
+ ]
263
+ if not candidates:
264
+ raise FileNotFoundError(
265
+ "SIRIUS output directory must contain a formula_identifications "
266
+ f"summary file: {path}"
267
+ )
268
+
269
+ def priority(file_path: Path) -> tuple[int, str]:
270
+ stem = file_path.stem
271
+ for idx, prefix in enumerate(_SIRIUS_FORMULA_FILES):
272
+ if stem.startswith(prefix):
273
+ return idx, file_path.name
274
+ return len(_SIRIUS_FORMULA_FILES), file_path.name
275
+
276
+ return sorted(candidates, key=priority)[0]
277
+
278
+
279
+ def _normalize_sirius(df: pd.DataFrame) -> pd.DataFrame:
280
+ normalized = df.loc[:, SIRIUS_COLUMNS].copy()
281
+ normalized["ID"] = normalized["ID"].map(_clean_identifier)
282
+ for column in SIRIUS_FORMULA_COLUMNS:
283
+ normalized[column] = normalized[column].map(_clean_formula)
284
+ for column in SIRIUS_ADDUCT_COLUMNS:
285
+ normalized[column] = normalized[column].map(_clean_text)
286
+ for column in SIRIUS_SCORE_COLUMNS:
287
+ normalized[column] = pd.to_numeric(normalized[column], errors="coerce")
288
+ return normalized.dropna(subset=["ID"]).reset_index(drop=True)
289
+
290
+
291
+ def _normalize_sirius_native(df: pd.DataFrame) -> pd.DataFrame:
292
+ id_column = _first_existing(df, _SIRIUS_ID_COLUMNS)
293
+ formula_column = _first_existing(df, _SIRIUS_FORMULA_COLUMNS)
294
+ adduct_column = _first_existing(df, _SIRIUS_ADDUCT_COLUMNS)
295
+ score_column = _first_existing(df, _SIRIUS_SCORE_COLUMNS)
296
+ rank_column = _first_existing(df, _SIRIUS_RANK_COLUMNS)
297
+ if not all((id_column, formula_column, adduct_column, score_column)):
298
+ raise ValueError(
299
+ "Unsupported SIRIUS result format. Expected a native/original "
300
+ "formula_identifications summary or output directory, or an "
301
+ "msfiddle-normalized CSV. Native summaries must include "
302
+ "identifier, formula, adduct, and score columns."
303
+ )
304
+
305
+ native = df.copy()
306
+ native["_msfiddle_id"] = native[id_column].map(_clean_identifier)
307
+ native["_msfiddle_formula"] = native[formula_column].map(_clean_formula)
308
+ native["_msfiddle_adduct"] = native[adduct_column].map(_clean_text)
309
+ native["_msfiddle_score"] = pd.to_numeric(native[score_column], errors="coerce")
310
+ if rank_column is not None:
311
+ native["_msfiddle_rank"] = pd.to_numeric(native[rank_column], errors="coerce")
312
+ else:
313
+ native["_msfiddle_rank"] = pd.NA
314
+ native = native.dropna(subset=["_msfiddle_id", "_msfiddle_formula"])
315
+
316
+ rows: list[dict[str, object]] = []
317
+ for spec_id, group in native.groupby("_msfiddle_id", sort=False):
318
+ group = group.sort_values(
319
+ by=["_msfiddle_rank", "_msfiddle_score"],
320
+ ascending=[True, False],
321
+ na_position="last",
322
+ )
323
+ out: dict[str, object] = {"ID": spec_id}
324
+ for idx, (_, row) in enumerate(group.head(5).iterrows(), start=1):
325
+ out[f"Pred Formula ({idx})"] = row["_msfiddle_formula"]
326
+ out[f"Pred Adduct ({idx})"] = row["_msfiddle_adduct"]
327
+ out[f"SIRIUS Score ({idx})"] = row["_msfiddle_score"]
328
+ rows.append(out)
329
+ return _normalize_sirius(pd.DataFrame(rows, columns=SIRIUS_COLUMNS))
330
+
331
+
332
+ def _first_existing(df: pd.DataFrame, candidates: Iterable[str]) -> str | None:
333
+ df_columns = list(df.columns)
334
+ for candidate in candidates:
335
+ if candidate in df_columns:
336
+ return candidate
337
+ canonical_columns = {_canonical_column(column): column for column in df_columns}
338
+ for candidate in candidates:
339
+ match = canonical_columns.get(_canonical_column(candidate))
340
+ if match is not None:
341
+ return match
342
+ return None
343
+
344
+
345
+ def _canonical_column(column: object) -> str:
346
+ text = str(column).strip().lstrip("\ufeff")
347
+ if text.lower().startswith("sirius_"):
348
+ text = text[7:]
349
+ return re.sub(r"[^a-z0-9]+", "", text.lower())
350
+
351
+
352
+ def _clean_identifier(value: object) -> object:
353
+ text = _clean_text(value)
354
+ if pd.isna(text):
355
+ return pd.NA
356
+ return str(text)
357
+
358
+
359
+ def _clean_formula(value: object) -> object:
360
+ text = _clean_text(value)
361
+ if pd.isna(text):
362
+ return pd.NA
363
+ # SIRIUS summaries may represent adduct-resolved formulas as
364
+ # "[C20H17NO6 + H]+". Keep only the neutral molecular formula.
365
+ match = re.match(r"^\[?([A-Z][A-Za-z0-9]*)", str(text))
366
+ return match.group(1) if match else text
367
+
368
+
369
+ def _clean_text(value: object) -> object:
370
+ if pd.isna(value):
371
+ return pd.NA
372
+ text = str(value).strip()
373
+ if text.lower() in {"", "na", "nan", "none", "null"}:
374
+ return pd.NA
375
+ return text
376
+
377
+
378
+ def _to_number(value: object) -> object:
379
+ number = pd.to_numeric(pd.Series([value]), errors="coerce").iloc[0]
380
+ return number if pd.notna(number) else pd.NA
@@ -173,10 +173,25 @@ def main():
173
173
 
174
174
  # Add optional arguments
175
175
  parser.add_argument(
176
- "--buddy_path", type=str, default="", help="Path to saved BUDDY's results"
176
+ "--buddy_path",
177
+ type=str,
178
+ default="",
179
+ help=(
180
+ "Path to BUDDY results: native/original msbuddy output directory "
181
+ "or summary TSV. The msfiddle-normalized CSV is also accepted but "
182
+ "deprecated and will be removed in 3.0.0."
183
+ ),
177
184
  )
178
185
  parser.add_argument(
179
- "--sirius_path", type=str, default="", help="Path to saved SIRIUS's results"
186
+ "--sirius_path",
187
+ type=str,
188
+ default="",
189
+ help=(
190
+ "Path to SIRIUS results: native/original formula_identifications "
191
+ "summary or SIRIUS summary output directory. The "
192
+ "msfiddle-normalized CSV is also accepted but deprecated and will "
193
+ "be removed in 3.0.0."
194
+ ),
180
195
  )
181
196
  parser.add_argument(
182
197
  "--seed", type=int, default=42, help="Seed for random functions"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msfiddle
3
- Version: 2.0.1
3
+ Version: 2.1.0
4
4
  Summary: A package for predicting chemical formulas from tandem mass spectra
5
5
  Home-page: https://github.com/JosieHong/msfiddle
6
6
  Author: Yuhui Hong
@@ -64,17 +64,13 @@ For the full experimental codebase, see https://github.com/JosieHong/FIDDLE.
64
64
  ## Installation
65
65
 
66
66
  ```bash
67
- pip install msfiddle
67
+ pip install msfiddle # base install, no PyTorch
68
+ pip install "msfiddle[inference]" # base install + PyTorch (needed for predictions and the CLI)
68
69
  ```
69
70
 
70
- PyTorch is required for inference. Install the optional inference extra,
71
- or install PyTorch separately for your platform:
72
- ```bash
73
- pip install "msfiddle[inference]"
74
- ```
75
-
76
- See the official PyTorch installation guide for custom CUDA builds:
77
- https://pytorch.org/get-started/locally/.
71
+ For a custom PyTorch build (CUDA, ROCm, Apple Silicon, etc.), install torch
72
+ first via the [official guide](https://pytorch.org/get-started/locally/), then
73
+ `pip install msfiddle`.
78
74
 
79
75
  ## Usage
80
76
 
@@ -90,7 +86,7 @@ msfiddle-download-models --destination /path/to/models \
90
86
  --models fiddle_tcn_qtof fiddle_rescore_qtof
91
87
  ```
92
88
 
93
- `msfiddle` 2.0.1 reuses the FIDDLE `v2.0.0` checkpoint assets.
89
+ All `msfiddle` 2.x releases reuse the FIDDLE `v2.0.0` checkpoint assets.
94
90
 
95
91
  Run the packaged demo:
96
92
  ```bash
@@ -190,6 +186,44 @@ The Python `predict_from_spectrum()` API returns a list of candidate dictionarie
190
186
  `predict_batch()` returns one record per input spectrum with `id`, `candidates`,
191
187
  and `metadata`.
192
188
 
189
+ ### Native/original BUDDY and SIRIUS outputs
190
+
191
+ Use the same MGF input file to generate native
192
+ [BUDDY/msbuddy](https://msbuddy.readthedocs.io/en/latest/cmdapi.html) and
193
+ [SIRIUS](https://v6.docs.sirius-ms.io/cli/) outputs. `msfiddle` accepts these
194
+ native/original files directly through `--buddy_path` and `--sirius_path`. The
195
+ older msfiddle-normalized CSV formats documented in `docs/formats.rst` are
196
+ **deprecated** and will be removed in `msfiddle` 3.0.0; loading them emits a
197
+ `DeprecationWarning`.
198
+
199
+ ```bash
200
+ msbuddy -mgf /path/to/data.mgf \
201
+ -output /path/to/buddy_output \
202
+ -ms orbitrap \
203
+ -p -n_cpu 12 \
204
+ -d -hal
205
+ ```
206
+
207
+ `msbuddy` writes `msbuddy_result_summary.tsv`; `-d` also writes detailed
208
+ per-query `formula_results.tsv` files. Pass either the summary file or the
209
+ output directory to `--buddy_path`. The full output directory is preferred
210
+ because it includes per-candidate FDR scores for ranks beyond rank 1.
211
+
212
+ ```bash
213
+ sirius --input /path/to/data.mgf \
214
+ --project /path/to/sirius_project \
215
+ formulas --profile orbitrap
216
+
217
+ sirius --project /path/to/sirius_project \
218
+ summaries --top-k-summary=5 \
219
+ --output /path/to/sirius_output
220
+ ```
221
+
222
+ SIRIUS writes a project space and exports summary files such as
223
+ `formula_identifications.tsv`. Pass either a formula-identification summary file
224
+ or the summary output directory to `--sirius_path`.
225
+ SIRIUS 6 may require `sirius login` before formula computation.
226
+
193
227
  ### MGF input
194
228
 
195
229
  The required MGF fields are `TITLE`, `PRECURSOR_MZ`, `PRECURSOR_TYPE`, and
@@ -5,6 +5,7 @@ msfiddle/__init__.py
5
5
  msfiddle/api.py
6
6
  msfiddle/dataset.py
7
7
  msfiddle/download.py
8
+ msfiddle/external_results.py
8
9
  msfiddle/main.py
9
10
  msfiddle/model_tcn.py
10
11
  msfiddle.egg-info/PKG-INFO
@@ -31,6 +32,7 @@ msfiddle/utils/pkl_utils.py
31
32
  msfiddle/utils/refine_utils.py
32
33
  tests/test_api.py
33
34
  tests/test_download.py
35
+ tests/test_external_results.py
34
36
  tests/test_main.py
35
37
  tests/test_mol_utils.py
36
38
  tests/test_msms_utils.py
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="msfiddle",
8
- version="2.0.1",
8
+ version="2.1.0",
9
9
  author="Yuhui Hong",
10
10
  author_email="josieexception@outlook.com",
11
11
  description="A package for predicting chemical formulas from tandem mass spectra",
@@ -1,7 +1,9 @@
1
1
  import pytest
2
+ import numpy as np
3
+ import pandas as pd
2
4
 
3
5
  import msfiddle.api as api
4
- from msfiddle.api import MsFiddlePredictor, _ModelPrediction
6
+ from msfiddle.api import MsFiddlePredictor, _EncodedSpectrum, _ModelPrediction
5
7
 
6
8
  VALID_MZ = [60.0, 85.0, 100.0, 125.0, 150.0]
7
9
  VALID_INTENSITY = [10.0, 50.0, 20.0, 35.0, 15.0]
@@ -160,3 +162,101 @@ def test_predict_batch_preserves_order_ids_and_candidate_shape(monkeypatch, tmp_
160
162
  assert result[0]["candidates"][0]["mass"] == pytest.approx(122.073)
161
163
  assert result[0]["candidates"][0]["metadata"]["raw_rescore"] == 1.2
162
164
  assert result[0]["metadata"]["adduct"] == VALID_ADDUCT
165
+
166
+
167
+ def test_predict_mgf_loads_native_external_outputs(monkeypatch, tmp_path):
168
+ predictor = MsFiddlePredictor(
169
+ resume_path=tmp_path / "missing_tcn.pt",
170
+ rescore_resume_path=tmp_path / "missing_rescore.pt",
171
+ )
172
+ spectrum = _EncodedSpectrum(
173
+ id="spec-1",
174
+ precursor_type="[M-H]-",
175
+ precursor_mz=100.0,
176
+ collision_energy="50",
177
+ spec=np.zeros(10, dtype=np.float32),
178
+ env=np.zeros(3, dtype=np.float32),
179
+ neutral_add=np.zeros(13, dtype=np.float32),
180
+ )
181
+
182
+ buddy_dir = tmp_path / "buddy_output"
183
+ buddy_dir.mkdir()
184
+ pd.DataFrame(
185
+ [
186
+ {
187
+ "identifier": "spec-1",
188
+ "mz": 100.0,
189
+ "rt": "NA",
190
+ "adduct": "[M-H]-",
191
+ "formula_rank_1": "C4H6N4O3",
192
+ "estimated_fdr": 0.2,
193
+ "formula_rank_2": "C5H7NO2",
194
+ "formula_rank_3": "NA",
195
+ "formula_rank_4": "NA",
196
+ "formula_rank_5": "NA",
197
+ }
198
+ ]
199
+ ).to_csv(buddy_dir / "msbuddy_result_summary.tsv", sep="\t", index=False)
200
+ detail_dir = buddy_dir / "spec-1_mz_100.0_rt_NA"
201
+ detail_dir.mkdir()
202
+ pd.DataFrame(
203
+ [
204
+ {"rank": 1, "formula": "C4H6N4O3", "estimated_fdr": 0.2},
205
+ {"rank": 2, "formula": "C5H7NO2", "estimated_fdr": 0.4},
206
+ ]
207
+ ).to_csv(detail_dir / "formula_results.tsv", sep="\t", index=False)
208
+
209
+ sirius_path = tmp_path / "formula_identifications_top-5.tsv"
210
+ pd.DataFrame(
211
+ [
212
+ {
213
+ "name": "spec-1",
214
+ "rank": 1,
215
+ "molecularFormula": "C6H12O6",
216
+ "adduct": "[M - H]-",
217
+ "SiriusScore": 1.0,
218
+ }
219
+ ]
220
+ ).to_csv(sirius_path, sep="\t", index=False)
221
+
222
+ seen_extra_formulas = []
223
+
224
+ def fake_load_mgf_spectra(self, test_data_path):
225
+ return [spectrum]
226
+
227
+ def fake_predict_encoded(self, encoded, *, batch_size):
228
+ return [
229
+ _ModelPrediction(
230
+ spectrum=encoded[0],
231
+ vector_string="1;2;3",
232
+ pred_formula="C3H7NO2",
233
+ pred_mass=89.0,
234
+ pred_atom_count=12.0,
235
+ pred_hc_count=1.5,
236
+ prediction_time=0.01,
237
+ )
238
+ ]
239
+
240
+ def fake_refine_and_rescore(self, prediction, top_k, *, extra_formulas=None):
241
+ seen_extra_formulas.extend(extra_formulas or [])
242
+ return (
243
+ {"formula": ["C4H6N4O3"], "mass": [146.0], "rescore": [0.9]},
244
+ 146.0,
245
+ )
246
+
247
+ monkeypatch.setattr(MsFiddlePredictor, "_load_mgf_spectra", fake_load_mgf_spectra)
248
+ monkeypatch.setattr(MsFiddlePredictor, "_predict_encoded", fake_predict_encoded)
249
+ monkeypatch.setattr(
250
+ MsFiddlePredictor, "_refine_and_rescore", fake_refine_and_rescore
251
+ )
252
+
253
+ result = predictor.predict_mgf(
254
+ tmp_path / "unused.mgf",
255
+ buddy_path=buddy_dir,
256
+ sirius_path=sirius_path,
257
+ top_k=1,
258
+ )
259
+
260
+ assert seen_extra_formulas == ["C4H6N4O3", "C5H7NO2", "C6H12O6"]
261
+ assert result.loc[0, "ID"] == "spec-1"
262
+ assert result.loc[0, "Refined Formula (0)"] == "C4H6N4O3"
@@ -0,0 +1,282 @@
1
+ import pandas as pd
2
+ import pytest
3
+
4
+ from msfiddle.external_results import load_buddy_results, load_sirius_results
5
+
6
+
7
+ def test_load_buddy_results_preserves_normalized_csv(tmp_path):
8
+ path = tmp_path / "buddy.csv"
9
+ pd.DataFrame(
10
+ [
11
+ {
12
+ "ID": "spec-1",
13
+ "Adduct": "[M-H]-",
14
+ "Pred Formula (1)": "C4H6N4O3",
15
+ "Pred Formula (2)": "NA",
16
+ "Pred Formula (3)": "NA",
17
+ "Pred Formula (4)": "NA",
18
+ "Pred Formula (5)": "NA",
19
+ "BUDDY Score (1)": "0.1",
20
+ "BUDDY Score (2)": "NA",
21
+ "BUDDY Score (3)": "NA",
22
+ "BUDDY Score (4)": "NA",
23
+ "BUDDY Score (5)": "NA",
24
+ }
25
+ ]
26
+ ).to_csv(path, index=False)
27
+
28
+ with pytest.warns(DeprecationWarning, match="msfiddle-normalized BUDDY"):
29
+ result = load_buddy_results(path)
30
+
31
+ assert result.loc[0, "ID"] == "spec-1"
32
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
33
+ assert result.loc[0, "BUDDY Score (1)"] == pytest.approx(0.1)
34
+ assert pd.isna(result.loc[0, "Pred Formula (2)"])
35
+
36
+
37
+ def test_load_buddy_results_converts_msbuddy_summary(tmp_path):
38
+ path = tmp_path / "msbuddy_result_summary.tsv"
39
+ pd.DataFrame(
40
+ [
41
+ {
42
+ "identifier": "spec-1",
43
+ "mz": 100.0,
44
+ "rt": "NA",
45
+ "adduct": "[M-H]-",
46
+ "formula_rank_1": "C4H6N4O3",
47
+ "estimated_fdr": 0.2,
48
+ "formula_rank_2": "C5H7NO2",
49
+ "formula_rank_3": "NA",
50
+ "formula_rank_4": "NA",
51
+ "formula_rank_5": "NA",
52
+ }
53
+ ]
54
+ ).to_csv(path, sep="\t", index=False)
55
+
56
+ result = load_buddy_results(path)
57
+
58
+ assert result.loc[0, "ID"] == "spec-1"
59
+ assert result.loc[0, "Adduct"] == "[M-H]-"
60
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
61
+ assert result.loc[0, "BUDDY Score (1)"] == pytest.approx(0.2)
62
+ assert result.loc[0, "Pred Formula (2)"] == "C5H7NO2"
63
+ assert pd.isna(result.loc[0, "BUDDY Score (2)"])
64
+
65
+
66
+ def test_load_buddy_results_uses_msbuddy_detail_scores_from_directory(tmp_path):
67
+ summary = tmp_path / "msbuddy_result_summary.tsv"
68
+ pd.DataFrame(
69
+ [
70
+ {
71
+ "identifier": "spec-1",
72
+ "mz": 100.0,
73
+ "rt": "NA",
74
+ "adduct": "[M-H]-",
75
+ "formula_rank_1": "C4H6N4O3",
76
+ "estimated_fdr": 0.2,
77
+ "formula_rank_2": "C5H7NO2",
78
+ "formula_rank_3": "NA",
79
+ "formula_rank_4": "NA",
80
+ "formula_rank_5": "NA",
81
+ }
82
+ ]
83
+ ).to_csv(summary, sep="\t", index=False)
84
+ detail_dir = tmp_path / "spec-1_mz_100.0_rt_NA"
85
+ detail_dir.mkdir()
86
+ pd.DataFrame(
87
+ [
88
+ {"rank": 2, "formula": "C5H7NO2", "estimated_fdr": 0.4},
89
+ {"rank": 1, "formula": "C4H6N4O3", "estimated_fdr": 0.2},
90
+ ]
91
+ ).to_csv(detail_dir / "formula_results.tsv", sep="\t", index=False)
92
+
93
+ result = load_buddy_results(tmp_path)
94
+
95
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
96
+ assert result.loc[0, "BUDDY Score (1)"] == pytest.approx(0.2)
97
+ assert result.loc[0, "Pred Formula (2)"] == "C5H7NO2"
98
+ assert result.loc[0, "BUDDY Score (2)"] == pytest.approx(0.4)
99
+
100
+
101
+ def test_load_buddy_results_matches_detail_dir_when_summary_mz_format_differs(tmp_path):
102
+ # Real msbuddy rounds mz in the summary, but a future version (or a
103
+ # hand-edited summary) could disagree with the directory name. The
104
+ # directory's identifier prefix alone should be enough to match.
105
+ summary = tmp_path / "msbuddy_result_summary.tsv"
106
+ pd.DataFrame(
107
+ [
108
+ {
109
+ "identifier": "spec-1",
110
+ "mz": 111.020160000, # padded zeros vs. dir name's "111.0202"
111
+ "rt": "NA",
112
+ "adduct": "[M-H]-",
113
+ "formula_rank_1": "C4H4N2O2",
114
+ "estimated_fdr": 0.0,
115
+ "formula_rank_2": "NA",
116
+ "formula_rank_3": "NA",
117
+ "formula_rank_4": "NA",
118
+ "formula_rank_5": "NA",
119
+ }
120
+ ]
121
+ ).to_csv(summary, sep="\t", index=False)
122
+ detail_dir = tmp_path / "spec-1_mz_111.0202_rt_NA"
123
+ detail_dir.mkdir()
124
+ pd.DataFrame(
125
+ [
126
+ {"rank": 1, "formula": "C4H4N2O2", "estimated_fdr": 0.0},
127
+ {"rank": 2, "formula": "C5H8O2", "estimated_fdr": 0.5},
128
+ ]
129
+ ).to_csv(detail_dir / "formula_results.tsv", sep="\t", index=False)
130
+
131
+ result = load_buddy_results(tmp_path)
132
+
133
+ assert result.loc[0, "Pred Formula (1)"] == "C4H4N2O2"
134
+ assert result.loc[0, "Pred Formula (2)"] == "C5H8O2"
135
+ assert result.loc[0, "BUDDY Score (2)"] == pytest.approx(0.5)
136
+
137
+
138
+ def test_load_buddy_results_drops_rows_with_missing_identifier(tmp_path):
139
+ path = tmp_path / "msbuddy_result_summary.tsv"
140
+ pd.DataFrame(
141
+ [
142
+ {
143
+ "identifier": "NA",
144
+ "mz": 100.0,
145
+ "rt": "NA",
146
+ "adduct": "[M-H]-",
147
+ "formula_rank_1": "C4H6N4O3",
148
+ "estimated_fdr": 0.2,
149
+ "formula_rank_2": "NA",
150
+ "formula_rank_3": "NA",
151
+ "formula_rank_4": "NA",
152
+ "formula_rank_5": "NA",
153
+ },
154
+ {
155
+ "identifier": "spec-1",
156
+ "mz": 100.0,
157
+ "rt": "NA",
158
+ "adduct": "[M-H]-",
159
+ "formula_rank_1": "C5H7NO2",
160
+ "estimated_fdr": 0.3,
161
+ "formula_rank_2": "NA",
162
+ "formula_rank_3": "NA",
163
+ "formula_rank_4": "NA",
164
+ "formula_rank_5": "NA",
165
+ },
166
+ ]
167
+ ).to_csv(path, sep="\t", index=False)
168
+
169
+ result = load_buddy_results(path)
170
+
171
+ assert len(result) == 1
172
+ assert result.loc[0, "ID"] == "spec-1"
173
+
174
+
175
+ def test_load_sirius_results_preserves_normalized_csv(tmp_path):
176
+ path = tmp_path / "sirius.csv"
177
+ pd.DataFrame(
178
+ [
179
+ {
180
+ "ID": "spec-1",
181
+ "Pred Formula (1)": "C3H7NO2",
182
+ "Pred Adduct (1)": "[M - H]-",
183
+ "SIRIUS Score (1)": "12.5",
184
+ "Pred Formula (2)": "NA",
185
+ "Pred Adduct (2)": "NA",
186
+ "SIRIUS Score (2)": "NA",
187
+ "Pred Formula (3)": "NA",
188
+ "Pred Adduct (3)": "NA",
189
+ "SIRIUS Score (3)": "NA",
190
+ "Pred Formula (4)": "NA",
191
+ "Pred Adduct (4)": "NA",
192
+ "SIRIUS Score (4)": "NA",
193
+ "Pred Formula (5)": "NA",
194
+ "Pred Adduct (5)": "NA",
195
+ "SIRIUS Score (5)": "NA",
196
+ }
197
+ ]
198
+ ).to_csv(path, index=False)
199
+
200
+ with pytest.warns(DeprecationWarning, match="msfiddle-normalized SIRIUS"):
201
+ result = load_sirius_results(path)
202
+
203
+ assert result.loc[0, "ID"] == "spec-1"
204
+ assert result.loc[0, "Pred Formula (1)"] == "C3H7NO2"
205
+ assert result.loc[0, "SIRIUS Score (1)"] == pytest.approx(12.5)
206
+ assert pd.isna(result.loc[0, "Pred Formula (2)"])
207
+
208
+
209
+ def test_load_sirius_results_converts_native_top_k_summary(tmp_path):
210
+ path = tmp_path / "formula_identifications_top-5.tsv"
211
+ pd.DataFrame(
212
+ [
213
+ {
214
+ "name": "spec-1",
215
+ "rank": 2,
216
+ "molecularFormula": "C5H7NO2",
217
+ "adduct": "[M - H]-",
218
+ "SiriusScore": 5.0,
219
+ },
220
+ {
221
+ "name": "spec-1",
222
+ "rank": 1,
223
+ "molecularFormula": "[C4H6N4O3 - H]-",
224
+ "adduct": "[M - H]-",
225
+ "SiriusScore": 7.0,
226
+ },
227
+ ]
228
+ ).to_csv(path, sep="\t", index=False)
229
+
230
+ result = load_sirius_results(path)
231
+
232
+ assert result.loc[0, "ID"] == "spec-1"
233
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
234
+ assert result.loc[0, "Pred Adduct (1)"] == "[M - H]-"
235
+ assert result.loc[0, "SIRIUS Score (1)"] == pytest.approx(7.0)
236
+ assert result.loc[0, "Pred Formula (2)"] == "C5H7NO2"
237
+
238
+
239
+ def test_load_sirius_results_finds_formula_summary_in_directory(tmp_path):
240
+ pd.DataFrame(
241
+ [
242
+ {
243
+ "mappingFeatureId": "spec-1",
244
+ "molecularFormula": "C4H6N4O3",
245
+ "adduct": "[M - H]-",
246
+ "rankingScore": 0.8,
247
+ }
248
+ ]
249
+ ).to_csv(tmp_path / "formula_identifications.tsv", sep="\t", index=False)
250
+
251
+ result = load_sirius_results(tmp_path)
252
+
253
+ assert result.loc[0, "ID"] == "spec-1"
254
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
255
+ assert result.loc[0, "SIRIUS Score (1)"] == pytest.approx(0.8)
256
+
257
+
258
+ def test_load_sirius_results_accepts_prefixed_column_variants(tmp_path):
259
+ path = tmp_path / "formula_identifications_top-5.tsv"
260
+ pd.DataFrame(
261
+ [
262
+ {
263
+ "SIRIUS_mappingFeatureId": "spec-1",
264
+ "SIRIUS_formula": "C5H7NO2",
265
+ "SIRIUS_ionType": "[M - H]-",
266
+ "SIRIUS_rankingScore": 0.4,
267
+ },
268
+ {
269
+ "SIRIUS_mappingFeatureId": "spec-1",
270
+ "SIRIUS_formula": "C4H6N4O3",
271
+ "SIRIUS_ionType": "[M - H]-",
272
+ "SIRIUS_rankingScore": 0.9,
273
+ },
274
+ ]
275
+ ).to_csv(path, sep="\t", index=False)
276
+
277
+ result = load_sirius_results(path)
278
+
279
+ assert result.loc[0, "ID"] == "spec-1"
280
+ assert result.loc[0, "Pred Formula (1)"] == "C4H6N4O3"
281
+ assert result.loc[0, "SIRIUS Score (1)"] == pytest.approx(0.9)
282
+ assert result.loc[0, "Pred Formula (2)"] == "C5H7NO2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes