corems.mass_spectra.output.mgf

SIRIUS-compatible MGF export for LC-MS mass features.

  1"""SIRIUS-compatible MGF export for LC-MS mass features."""
  2
  3import warnings
  4from pathlib import Path
  5
  6import numpy as np
  7
  8
  9def sirius_charge(polarity: str) -> str:
 10    """Map LCMS polarity to a SIRIUS CHARGE token.
 11
 12    Parameters
 13    ----------
 14    polarity : str
 15        ``'positive'`` or ``'negative'``.
 16
 17    Returns
 18    -------
 19    str
 20        ``'1+'`` or ``'1-'``.
 21    """
 22    value = (polarity or "").strip().lower()
 23    if value == "positive":
 24        return "1+"
 25    if value == "negative":
 26        return "1-"
 27    raise ValueError(
 28        "Polarity not set for dataset, must be a either 'positive' or 'negative'"
 29    )
 30
 31
 32def format_ion_block(headers: dict, mz, intensity) -> str:
 33    """Serialize one MGF spectrum as ``BEGIN IONS`` ... ``END IONS``.
 34
 35    Parameters
 36    ----------
 37    headers : dict
 38        MGF ``NAME=VALUE`` pairs, written in insertion order.
 39    mz, intensity : array_like
 40        Peak lists.
 41
 42    Returns
 43    -------
 44    str
 45        One ion block including a trailing newline.
 46    """
 47    lines = ["BEGIN IONS"]
 48    for key, value in headers.items():
 49        if value is None:
 50            continue
 51        lines.append(f"{key}={value}")
 52    mz = np.asarray(mz, dtype=float)
 53    intensity = np.asarray(intensity, dtype=float)
 54    for mass, abund in zip(mz, intensity):
 55        lines.append(f"{mass} {abund}")
 56    lines.append("END IONS")
 57    return "\n".join(lines) + "\n"
 58
 59
 60def ensure_mgf_path(out_file_path) -> Path:
 61    """Return a Path, appending ``.mgf`` when no suffix is given."""
 62    path = Path(out_file_path)
 63    if path.suffix == "":
 64        path = path.with_suffix(".mgf")
 65    return path
 66
 67
 68def peak_mz_abundance(spectrum):
 69    """Return (mz, abundance) from processed peaks, else raw arrays.
 70
 71    A spectrum that has been through ``process_mass_spec`` is read from
 72    ``mspeaks`` even when that list is empty. Raw ``_mz_exp`` /
 73    ``_abundance`` are used only when the spectrum has not been processed.
 74
 75    Parameters
 76    ----------
 77    spectrum : MassSpectrum or None
 78        Spectrum to read.
 79
 80    Returns
 81    -------
 82    tuple of numpy.ndarray
 83        ``(mz, abundance)``. Empty arrays if ``spectrum`` is None, the
 84        processed peak list is empty, or raw arrays are missing.
 85    """
 86    if spectrum is None:
 87        return np.array([]), np.array([])
 88    # process_mass_spec sets _dynamic_range, including 0 when no peaks remain.
 89    processed = getattr(spectrum, "_dynamic_range", None) is not None
 90    mspeaks = getattr(spectrum, "mspeaks", None)
 91    if processed or mspeaks:
 92        if not mspeaks:
 93            return np.array([]), np.array([])
 94        return np.asarray(spectrum.mz_exp, dtype=float), np.asarray(
 95            spectrum.abundance, dtype=float
 96        )
 97    mz = getattr(spectrum, "_mz_exp", None)
 98    ab = getattr(spectrum, "_abundance", None)
 99    if mz is None or ab is None:
100        return np.array([]), np.array([])
101    return np.asarray(mz, dtype=float), np.asarray(ab, dtype=float)
102
103
104def _ms1_precursor_peak(feature):
105    """Return the feature precursor as a one-peak MS1 list.
106
107    Raises
108    ------
109    ValueError
110        If the feature has no precursor m/z.
111    """
112    mz = getattr(feature, "mz", None)
113    if mz is None:
114        raise ValueError("Mass feature is missing precursor m/z; cannot export MGF")
115    intensity = getattr(feature, "intensity", None)
116    if intensity is None:
117        intensity = 0.0
118    return np.array([float(mz)]), np.array([float(intensity)])
119
120
121def _scan_of_spectrum(feature, spec):
122    scan = getattr(spec, "scan_number", None)
123    if scan is not None:
124        return scan
125    for key, value in getattr(feature, "ms2_mass_spectra", {}).items():
126        if value is spec:
127            return key
128    return None
129
130
131def _ms2_spectra(feature, ms2_mode: str):
132    """Select MS2 spectra for one feature.
133
134    ``'best'`` returns ``feature.best_ms2``: the scan with the highest
135    library entropy similarity when ``ms2_similarity_results`` is
136    non-empty, otherwise the non-chimeric scan closest to the apex.
137    ``'all'`` returns every associated scan that has peaks.
138    """
139    if ms2_mode not in ("best", "all"):
140        raise ValueError(f"ms2_mode must be 'best' or 'all', got {ms2_mode!r}")
141    if ms2_mode == "best":
142        spec = feature.best_ms2
143        if spec is None:
144            return []
145        return [(_scan_of_spectrum(feature, spec), spec)]
146    spectra = []
147    for scan, spec in feature.ms2_mass_spectra.items():
148        mz, _ = peak_mz_abundance(spec)
149        if mz.size:
150            spectra.append((scan, spec))
151    return spectra
152
153
154def _ordered_headers(headers: dict) -> dict:
155    order = [
156        "FEATURE_ID",
157        "PEPMASS",
158        "CHARGE",
159        "MSLEVEL",
160        "TITLE",
161        "RTINSECONDS",
162        "SCANS",
163    ]
164    out = {}
165    for key in order:
166        if key in headers:
167            out[key] = headers[key]
168    for key, value in headers.items():
169        if key not in out:
170            out[key] = value
171    return out
172
173
174def _shared_headers(feature, feature_id, polarity, sample_name=None, title=None):
175    if title is None:
176        if sample_name:
177            title = f"{sample_name} feature {feature_id}"
178        else:
179            title = f"feature {feature_id}"
180    rt = getattr(feature, "retention_time", None)
181    rtinseconds = None if rt is None else float(rt) * 60.0
182    return {
183        "FEATURE_ID": feature_id,
184        "PEPMASS": feature.mz,
185        "CHARGE": sirius_charge(polarity),
186        "TITLE": title,
187        "RTINSECONDS": rtinseconds,
188    }
189
190
191def feature_mgf_text(
192    feature,
193    feature_id,
194    polarity,
195    *,
196    sample_name=None,
197    ms2_mode="best",
198    title=None,
199):
200    """Return MGF text for one feature, or None if MS2 is missing.
201
202    Parameters
203    ----------
204    feature : LCMSMassFeature
205        Mass feature with associated MS2 spectra. MS1 is the precursor
206        ``mz`` / ``intensity`` on the feature, not a scanned spectrum.
207    feature_id : hashable
208        Value written as ``FEATURE_ID`` (mass-feature id or consensus cluster).
209    polarity : str
210        ``'positive'`` or ``'negative'``.
211    sample_name : str, optional
212        Used in the default TITLE.
213    ms2_mode : {'best', 'all'}, optional
214        Which associated MS2 spectra to write. ``'best'`` (default)
215        writes ``LCMSMassFeature.best_ms2``. After a spectral search
216        that is the scan with the highest library entropy similarity;
217        with no search results it is the non-chimeric scan closest to
218        the apex. ``'all'`` writes every MS2 scan that has peaks.
219    title : str, optional
220        Override TITLE.
221
222    Returns
223    -------
224    str or None
225        One or more ion blocks, or None if the feature is incomplete.
226    """
227    mz1, ab1 = _ms1_precursor_peak(feature)
228    ms2_list = _ms2_spectra(feature, ms2_mode)
229    if not ms2_list:
230        return None
231
232    shared = _shared_headers(
233        feature, feature_id, polarity, sample_name=sample_name, title=title
234    )
235    blocks = []
236    ms1_headers = dict(shared)
237    ms1_headers["MSLEVEL"] = 1
238    ms1_headers["SCANS"] = getattr(feature, "apex_scan", None)
239    blocks.append(format_ion_block(_ordered_headers(ms1_headers), mz1, ab1))
240    for scan, spec in ms2_list:
241        mz2, ab2 = peak_mz_abundance(spec)
242        if mz2.size == 0:
243            continue
244        ms2_headers = dict(shared)
245        ms2_headers["MSLEVEL"] = 2
246        ms2_headers["SCANS"] = (
247            scan if scan is not None else getattr(spec, "scan_number", None)
248        )
249        blocks.append(format_ion_block(_ordered_headers(ms2_headers), mz2, ab2))
250    if len(blocks) < 2:
251        return None
252    return "".join(blocks)
253
254
255def write_feature_records_to_mgf(
256    records,
257    polarity,
258    out_file_path,
259    *,
260    ms2_mode="best",
261    overwrite=False,
262) -> Path:
263    """Write selected feature records to an MGF file.
264
265    Parameters
266    ----------
267    records : iterable of tuple
268        ``(feature_id, feature, sample_name)`` triples. ``sample_name`` may be None.
269    polarity : str
270        ``'positive'`` or ``'negative'``.
271    out_file_path : str or Path
272        Output path. ``.mgf`` is appended when no suffix is given.
273    ms2_mode : {'best', 'all'}, optional
274        Which associated MS2 spectra to write. ``'best'`` (default)
275        writes ``LCMSMassFeature.best_ms2``. After a spectral search
276        that is the scan with the highest library entropy similarity;
277        with no search results it is the non-chimeric scan closest to
278        the apex. ``'all'`` writes every MS2 scan that has peaks.
279    overwrite : bool, optional
280        Replace an existing file. Default is False.
281
282    Returns
283    -------
284    pathlib.Path
285        Path of the written MGF file.
286
287    Raises
288    ------
289    FileExistsError
290        If the path exists and ``overwrite`` is False.
291    ValueError
292        If no complete MS1/MS2 pairs remain after skipping.
293    """
294    path = ensure_mgf_path(out_file_path)
295    if path.exists() and not overwrite:
296        raise FileExistsError(f"MGF file already exists: {path}")
297
298    chunks = []
299    n_in = 0
300    n_skip = 0
301    for feature_id, feature, sample_name in records:
302        n_in += 1
303        text = feature_mgf_text(
304            feature,
305            feature_id,
306            polarity,
307            sample_name=sample_name,
308            ms2_mode=ms2_mode,
309        )
310        if text is None:
311            n_skip += 1
312            continue
313        chunks.append(text)
314
315    if not chunks:
316        raise ValueError(
317            "No complete MS1/MS2 feature pairs to export "
318            f"(skipped {n_skip} of {n_in} selected features)"
319        )
320    if n_skip:
321        warnings.warn(
322            f"Skipped {n_skip} of {n_in} features missing a usable MS2 spectrum",
323            UserWarning,
324        )
325    path.write_text("".join(chunks), encoding="utf-8")
326    return path
327
328
329def iter_collection_mgf_records(collection, cluster_ids=None):
330    """Build ``(cluster_id, mass_feature, sample_name)`` from consensus representatives.
331
332    Parameters
333    ----------
334    collection : LCMSCollection
335        Collection with consensus clustering and loaded representatives.
336    cluster_ids : iterable, optional
337        Cluster ids to export. Default is all representative clusters.
338
339    Returns
340    -------
341    list of tuple
342        ``(cluster_id, LCMSMassFeature, sample_name)``.
343
344    Raises
345    ------
346    ValueError
347        If representatives are missing, requested clusters are unknown, or
348        representative objects are not loaded on the samples.
349    """
350    if not hasattr(collection, "get_representative_mass_features_for_all_clusters"):
351        raise ValueError("Collection cannot provide consensus representatives")
352    reps = collection.get_representative_mass_features_for_all_clusters()
353    if reps is None or len(reps) == 0:
354        raise ValueError(
355            "No consensus representatives found. Run process_consensus_features() "
356            "with load_representatives=True, add_ms2=True first."
357        )
358    if cluster_ids is None:
359        selected = reps
360    else:
361        requested = list(cluster_ids)
362        selected = reps[reps["cluster"].isin(requested)]
363        found = set(selected["cluster"].tolist())
364        missing = [c for c in requested if c not in found]
365        if missing:
366            raise ValueError(f"Consensus cluster id(s) not found: {missing}")
367
368    records = []
369    lcms_map = getattr(collection, "_lcms", {})
370    for _, row in selected.iterrows():
371        cluster = row["cluster"]
372        sample_name = None
373        if "sample_name" in selected.columns:
374            val = row["sample_name"]
375            if not (isinstance(val, (float, np.floating)) and np.isnan(val)):
376                sample_name = val
377        if sample_name is None:
378            if "sample_id" not in selected.columns:
379                raise ValueError(
380                    "Cannot resolve sample_name from consensus representatives"
381                )
382            sample_name = collection.samples[int(row["sample_id"])]
383        mf_id = row["mf_id"]
384        sample = lcms_map.get(sample_name)
385        mass_features = (
386            getattr(sample, "mass_features", {}) if sample is not None else {}
387        )
388        if mf_id not in mass_features:
389            try:
390                mf_id = int(mf_id)
391            except (TypeError, ValueError):
392                mf_id = row["mf_id"]
393        if sample is None or mf_id not in mass_features:
394            raise ValueError(
395                "Representative mass features have not been loaded into samples. "
396                "Call process_consensus_features() with load_representatives=True, "
397                "add_ms2=True before exporting MGF."
398            )
399        records.append((cluster, mass_features[mf_id], sample_name))
400    return records
def sirius_charge(polarity: str) -> str:
10def sirius_charge(polarity: str) -> str:
11    """Map LCMS polarity to a SIRIUS CHARGE token.
12
13    Parameters
14    ----------
15    polarity : str
16        ``'positive'`` or ``'negative'``.
17
18    Returns
19    -------
20    str
21        ``'1+'`` or ``'1-'``.
22    """
23    value = (polarity or "").strip().lower()
24    if value == "positive":
25        return "1+"
26    if value == "negative":
27        return "1-"
28    raise ValueError(
29        "Polarity not set for dataset, must be a either 'positive' or 'negative'"
30    )

Map LCMS polarity to a SIRIUS CHARGE token.

Parameters
  • polarity (str): 'positive' or 'negative'.
Returns
  • str: '1+' or '1-'.
def format_ion_block(headers: dict, mz, intensity) -> str:
33def format_ion_block(headers: dict, mz, intensity) -> str:
34    """Serialize one MGF spectrum as ``BEGIN IONS`` ... ``END IONS``.
35
36    Parameters
37    ----------
38    headers : dict
39        MGF ``NAME=VALUE`` pairs, written in insertion order.
40    mz, intensity : array_like
41        Peak lists.
42
43    Returns
44    -------
45    str
46        One ion block including a trailing newline.
47    """
48    lines = ["BEGIN IONS"]
49    for key, value in headers.items():
50        if value is None:
51            continue
52        lines.append(f"{key}={value}")
53    mz = np.asarray(mz, dtype=float)
54    intensity = np.asarray(intensity, dtype=float)
55    for mass, abund in zip(mz, intensity):
56        lines.append(f"{mass} {abund}")
57    lines.append("END IONS")
58    return "\n".join(lines) + "\n"

Serialize one MGF spectrum as BEGIN IONS ... END IONS.

Parameters
  • headers (dict): MGF NAME=VALUE pairs, written in insertion order.
  • mz, intensity (array_like): Peak lists.
Returns
  • str: One ion block including a trailing newline.
def ensure_mgf_path(out_file_path) -> pathlib.Path:
61def ensure_mgf_path(out_file_path) -> Path:
62    """Return a Path, appending ``.mgf`` when no suffix is given."""
63    path = Path(out_file_path)
64    if path.suffix == "":
65        path = path.with_suffix(".mgf")
66    return path

Return a Path, appending corems.mass_spectra.output.mgf when no suffix is given.

def peak_mz_abundance(spectrum):
 69def peak_mz_abundance(spectrum):
 70    """Return (mz, abundance) from processed peaks, else raw arrays.
 71
 72    A spectrum that has been through ``process_mass_spec`` is read from
 73    ``mspeaks`` even when that list is empty. Raw ``_mz_exp`` /
 74    ``_abundance`` are used only when the spectrum has not been processed.
 75
 76    Parameters
 77    ----------
 78    spectrum : MassSpectrum or None
 79        Spectrum to read.
 80
 81    Returns
 82    -------
 83    tuple of numpy.ndarray
 84        ``(mz, abundance)``. Empty arrays if ``spectrum`` is None, the
 85        processed peak list is empty, or raw arrays are missing.
 86    """
 87    if spectrum is None:
 88        return np.array([]), np.array([])
 89    # process_mass_spec sets _dynamic_range, including 0 when no peaks remain.
 90    processed = getattr(spectrum, "_dynamic_range", None) is not None
 91    mspeaks = getattr(spectrum, "mspeaks", None)
 92    if processed or mspeaks:
 93        if not mspeaks:
 94            return np.array([]), np.array([])
 95        return np.asarray(spectrum.mz_exp, dtype=float), np.asarray(
 96            spectrum.abundance, dtype=float
 97        )
 98    mz = getattr(spectrum, "_mz_exp", None)
 99    ab = getattr(spectrum, "_abundance", None)
100    if mz is None or ab is None:
101        return np.array([]), np.array([])
102    return np.asarray(mz, dtype=float), np.asarray(ab, dtype=float)

Return (mz, abundance) from processed peaks, else raw arrays.

A spectrum that has been through process_mass_spec is read from mspeaks even when that list is empty. Raw _mz_exp / _abundance are used only when the spectrum has not been processed.

Parameters
  • spectrum (MassSpectrum or None): Spectrum to read.
Returns
  • tuple of numpy.ndarray: (mz, abundance). Empty arrays if spectrum is None, the processed peak list is empty, or raw arrays are missing.
def feature_mgf_text( feature, feature_id, polarity, *, sample_name=None, ms2_mode='best', title=None):
192def feature_mgf_text(
193    feature,
194    feature_id,
195    polarity,
196    *,
197    sample_name=None,
198    ms2_mode="best",
199    title=None,
200):
201    """Return MGF text for one feature, or None if MS2 is missing.
202
203    Parameters
204    ----------
205    feature : LCMSMassFeature
206        Mass feature with associated MS2 spectra. MS1 is the precursor
207        ``mz`` / ``intensity`` on the feature, not a scanned spectrum.
208    feature_id : hashable
209        Value written as ``FEATURE_ID`` (mass-feature id or consensus cluster).
210    polarity : str
211        ``'positive'`` or ``'negative'``.
212    sample_name : str, optional
213        Used in the default TITLE.
214    ms2_mode : {'best', 'all'}, optional
215        Which associated MS2 spectra to write. ``'best'`` (default)
216        writes ``LCMSMassFeature.best_ms2``. After a spectral search
217        that is the scan with the highest library entropy similarity;
218        with no search results it is the non-chimeric scan closest to
219        the apex. ``'all'`` writes every MS2 scan that has peaks.
220    title : str, optional
221        Override TITLE.
222
223    Returns
224    -------
225    str or None
226        One or more ion blocks, or None if the feature is incomplete.
227    """
228    mz1, ab1 = _ms1_precursor_peak(feature)
229    ms2_list = _ms2_spectra(feature, ms2_mode)
230    if not ms2_list:
231        return None
232
233    shared = _shared_headers(
234        feature, feature_id, polarity, sample_name=sample_name, title=title
235    )
236    blocks = []
237    ms1_headers = dict(shared)
238    ms1_headers["MSLEVEL"] = 1
239    ms1_headers["SCANS"] = getattr(feature, "apex_scan", None)
240    blocks.append(format_ion_block(_ordered_headers(ms1_headers), mz1, ab1))
241    for scan, spec in ms2_list:
242        mz2, ab2 = peak_mz_abundance(spec)
243        if mz2.size == 0:
244            continue
245        ms2_headers = dict(shared)
246        ms2_headers["MSLEVEL"] = 2
247        ms2_headers["SCANS"] = (
248            scan if scan is not None else getattr(spec, "scan_number", None)
249        )
250        blocks.append(format_ion_block(_ordered_headers(ms2_headers), mz2, ab2))
251    if len(blocks) < 2:
252        return None
253    return "".join(blocks)

Return MGF text for one feature, or None if MS2 is missing.

Parameters
  • feature (LCMSMassFeature): Mass feature with associated MS2 spectra. MS1 is the precursor mz / intensity on the feature, not a scanned spectrum.
  • feature_id (hashable): Value written as FEATURE_ID (mass-feature id or consensus cluster).
  • polarity (str): 'positive' or 'negative'.
  • sample_name (str, optional): Used in the default TITLE.
  • ms2_mode ({'best', 'all'}, optional): Which associated MS2 spectra to write. 'best' (default) writes LCMSMassFeature.best_ms2. After a spectral search that is the scan with the highest library entropy similarity; with no search results it is the non-chimeric scan closest to the apex. 'all' writes every MS2 scan that has peaks.
  • title (str, optional): Override TITLE.
Returns
  • str or None: One or more ion blocks, or None if the feature is incomplete.
def write_feature_records_to_mgf( records, polarity, out_file_path, *, ms2_mode='best', overwrite=False) -> pathlib.Path:
256def write_feature_records_to_mgf(
257    records,
258    polarity,
259    out_file_path,
260    *,
261    ms2_mode="best",
262    overwrite=False,
263) -> Path:
264    """Write selected feature records to an MGF file.
265
266    Parameters
267    ----------
268    records : iterable of tuple
269        ``(feature_id, feature, sample_name)`` triples. ``sample_name`` may be None.
270    polarity : str
271        ``'positive'`` or ``'negative'``.
272    out_file_path : str or Path
273        Output path. ``.mgf`` is appended when no suffix is given.
274    ms2_mode : {'best', 'all'}, optional
275        Which associated MS2 spectra to write. ``'best'`` (default)
276        writes ``LCMSMassFeature.best_ms2``. After a spectral search
277        that is the scan with the highest library entropy similarity;
278        with no search results it is the non-chimeric scan closest to
279        the apex. ``'all'`` writes every MS2 scan that has peaks.
280    overwrite : bool, optional
281        Replace an existing file. Default is False.
282
283    Returns
284    -------
285    pathlib.Path
286        Path of the written MGF file.
287
288    Raises
289    ------
290    FileExistsError
291        If the path exists and ``overwrite`` is False.
292    ValueError
293        If no complete MS1/MS2 pairs remain after skipping.
294    """
295    path = ensure_mgf_path(out_file_path)
296    if path.exists() and not overwrite:
297        raise FileExistsError(f"MGF file already exists: {path}")
298
299    chunks = []
300    n_in = 0
301    n_skip = 0
302    for feature_id, feature, sample_name in records:
303        n_in += 1
304        text = feature_mgf_text(
305            feature,
306            feature_id,
307            polarity,
308            sample_name=sample_name,
309            ms2_mode=ms2_mode,
310        )
311        if text is None:
312            n_skip += 1
313            continue
314        chunks.append(text)
315
316    if not chunks:
317        raise ValueError(
318            "No complete MS1/MS2 feature pairs to export "
319            f"(skipped {n_skip} of {n_in} selected features)"
320        )
321    if n_skip:
322        warnings.warn(
323            f"Skipped {n_skip} of {n_in} features missing a usable MS2 spectrum",
324            UserWarning,
325        )
326    path.write_text("".join(chunks), encoding="utf-8")
327    return path

Write selected feature records to an MGF file.

Parameters
  • records (iterable of tuple): (feature_id, feature, sample_name) triples. sample_name may be None.
  • polarity (str): 'positive' or 'negative'.
  • out_file_path (str or Path): Output path. corems.mass_spectra.output.mgf is appended when no suffix is given.
  • ms2_mode ({'best', 'all'}, optional): Which associated MS2 spectra to write. 'best' (default) writes LCMSMassFeature.best_ms2. After a spectral search that is the scan with the highest library entropy similarity; with no search results it is the non-chimeric scan closest to the apex. 'all' writes every MS2 scan that has peaks.
  • overwrite (bool, optional): Replace an existing file. Default is False.
Returns
  • pathlib.Path: Path of the written MGF file.
Raises
  • FileExistsError: If the path exists and overwrite is False.
  • ValueError: If no complete MS1/MS2 pairs remain after skipping.
def iter_collection_mgf_records(collection, cluster_ids=None):
330def iter_collection_mgf_records(collection, cluster_ids=None):
331    """Build ``(cluster_id, mass_feature, sample_name)`` from consensus representatives.
332
333    Parameters
334    ----------
335    collection : LCMSCollection
336        Collection with consensus clustering and loaded representatives.
337    cluster_ids : iterable, optional
338        Cluster ids to export. Default is all representative clusters.
339
340    Returns
341    -------
342    list of tuple
343        ``(cluster_id, LCMSMassFeature, sample_name)``.
344
345    Raises
346    ------
347    ValueError
348        If representatives are missing, requested clusters are unknown, or
349        representative objects are not loaded on the samples.
350    """
351    if not hasattr(collection, "get_representative_mass_features_for_all_clusters"):
352        raise ValueError("Collection cannot provide consensus representatives")
353    reps = collection.get_representative_mass_features_for_all_clusters()
354    if reps is None or len(reps) == 0:
355        raise ValueError(
356            "No consensus representatives found. Run process_consensus_features() "
357            "with load_representatives=True, add_ms2=True first."
358        )
359    if cluster_ids is None:
360        selected = reps
361    else:
362        requested = list(cluster_ids)
363        selected = reps[reps["cluster"].isin(requested)]
364        found = set(selected["cluster"].tolist())
365        missing = [c for c in requested if c not in found]
366        if missing:
367            raise ValueError(f"Consensus cluster id(s) not found: {missing}")
368
369    records = []
370    lcms_map = getattr(collection, "_lcms", {})
371    for _, row in selected.iterrows():
372        cluster = row["cluster"]
373        sample_name = None
374        if "sample_name" in selected.columns:
375            val = row["sample_name"]
376            if not (isinstance(val, (float, np.floating)) and np.isnan(val)):
377                sample_name = val
378        if sample_name is None:
379            if "sample_id" not in selected.columns:
380                raise ValueError(
381                    "Cannot resolve sample_name from consensus representatives"
382                )
383            sample_name = collection.samples[int(row["sample_id"])]
384        mf_id = row["mf_id"]
385        sample = lcms_map.get(sample_name)
386        mass_features = (
387            getattr(sample, "mass_features", {}) if sample is not None else {}
388        )
389        if mf_id not in mass_features:
390            try:
391                mf_id = int(mf_id)
392            except (TypeError, ValueError):
393                mf_id = row["mf_id"]
394        if sample is None or mf_id not in mass_features:
395            raise ValueError(
396                "Representative mass features have not been loaded into samples. "
397                "Call process_consensus_features() with load_representatives=True, "
398                "add_ms2=True before exporting MGF."
399            )
400        records.append((cluster, mass_features[mf_id], sample_name))
401    return records

Build (cluster_id, mass_feature, sample_name) from consensus representatives.

Parameters
  • collection (LCMSCollection): Collection with consensus clustering and loaded representatives.
  • cluster_ids (iterable, optional): Cluster ids to export. Default is all representative clusters.
Returns
  • list of tuple: (cluster_id, LCMSMassFeature, sample_name).
Raises
  • ValueError: If representatives are missing, requested clusters are unknown, or representative objects are not loaded on the samples.