corems.mass_spectra.output.export

   1__author__ = "Yuri E. Corilo"
   2__date__ = "Dec 14, 2010"
   3
   4
   5import csv
   6import json
   7import re
   8import uuid
   9import warnings
  10from datetime import datetime, timezone
  11from pathlib import Path
  12
  13import h5py
  14import numpy as np
  15import pandas as pd
  16from openpyxl import load_workbook
  17from pandas import DataFrame, ExcelWriter, read_excel
  18
  19from corems import __version__, corems_md5
  20from corems.encapsulation.output import parameter_to_dict
  21from corems.encapsulation.output.parameter_to_json import (
  22    dump_lcms_settings_json,
  23    dump_lcms_settings_toml,
  24    dump_lcms_collection_settings_json,
  25    dump_lcms_collection_settings_toml,
  26)
  27from corems.mass_spectrum.output.export import HighResMassSpecExport
  28from corems.molecular_formula.factory.MolecularFormulaFactory import MolecularFormula
  29from corems.molecular_id.calc.SpectralSimilarity import methods_name
  30
  31ion_type_dict = {
  32    # adduct : [atoms to add, atoms to subtract when calculating formula of ion
  33    "M+": [{}, {}],
  34    "[M]+": [{}, {}],
  35    "protonated": [{"H": 1}, {}],
  36    "[M+H]+": [{"H": 1}, {}],
  37    "[M+NH4]+": [{"N": 1, "H": 4}, {}],  # ammonium
  38    "[M+Na]+": [{"Na": 1}, {}],
  39    "[M+K]+": [{"K": 1}, {}],
  40    "[M+2Na+Cl]+": [{"Na": 2, "Cl": 1}, {}],
  41    "[M+2Na-H]+": [{"Na": 2}, {"H": 1}],
  42    "[M+C2H3Na2O2]+": [{"C": 2, "H": 3, "Na": 2, "O": 2}, {}],
  43    "[M+C4H10N3]+": [{"C": 4, "H": 10, "N": 3}, {}],
  44    "[M+NH4+ACN]+": [{"C": 2, "H": 7, "N": 2}, {}],
  45    "[M+H-H2O]+": [{}, {"H": 1, "O": 1}],
  46    "de-protonated": [{}, {"H": 1}],
  47    "[M-H]-": [{}, {"H": 1}],
  48    "[M+Cl]-": [{"Cl": 1}, {}],
  49    "[M+HCOO]-": [{"C": 1, "H": 1, "O": 2}, {}],  # formate
  50    "[M+CH3COO]-": [{"C": 2, "H": 3, "O": 2}, {}],  # acetate
  51    "[M+2NaAc+Cl]-": [{"Na": 2, "C": 2, "H": 3, "O": 2, "Cl": 1}, {}],
  52    "[M+K-2H]-": [{"K": 1}, {"H": 2}],
  53    "[M+Na-2H]-": [{"Na": 1}, {"H": 2}],
  54}
  55
  56
  57class LowResGCMSExport:
  58    """A class to export low resolution GC-MS data.
  59
  60    This class provides methods to export low resolution GC-MS data to various formats such as Excel, CSV, HDF5, and Pandas DataFrame.
  61
  62    Parameters:
  63    ----------
  64    out_file_path : str
  65        The output file path.
  66    gcms : object
  67        The low resolution GCMS object.
  68
  69    Attributes:
  70    ----------
  71    output_file : Path
  72        The output file path as a Path object.
  73    gcms : object
  74        The low resolution GCMS object.
  75
  76    Methods:
  77    -------
  78    * get_pandas_df(id_label="corems:"). Get the exported data as a Pandas DataFrame.
  79    * get_json(nan=False, id_label="corems:"). Get the exported data as a JSON string.
  80    * to_pandas(write_metadata=True, id_label="corems:"). Export the data to a Pandas DataFrame and save it as a pickle file.
  81    * to_excel(write_mode='a', write_metadata=True, id_label="corems:"),
  82        Export the data to an Excel file.
  83    * to_csv(separate_output=False, write_mode="w", write_metadata=True, id_label="corems:").
  84        Export the data to a CSV file.
  85    * to_hdf(id_label="corems:").
  86        Export the data to an HDF5 file.
  87    * get_data_stats(gcms).
  88        Get statistics about the GCMS data.
  89
  90    """
  91
  92    def __init__(self, out_file_path, gcms):
  93        self.output_file = Path(out_file_path)
  94
  95        self.gcms = gcms
  96
  97        self._init_columns()
  98
  99    def _init_columns(self):
 100        """Initialize the column names for the exported data.
 101
 102        Returns:
 103        -------
 104        list
 105            The list of column names.
 106        """
 107
 108        columns = [
 109            "Sample name",
 110            "Peak Index",
 111            "Retention Time",
 112            "Retention Time Ref",
 113            "Peak Height",
 114            "Peak Area",
 115            "Retention index",
 116            "Retention index Ref",
 117            "Retention Index Score",
 118            "Similarity Score",
 119            "Spectral Similarity Score",
 120            "Compound Name",
 121            "Chebi ID",
 122            "Kegg Compound ID",
 123            "Inchi",
 124            "Inchi Key",
 125            "Smiles",
 126            "Molecular Formula",
 127            "IUPAC Name",
 128            "Traditional Name",
 129            "Common Name",
 130            "Derivatization",
 131        ]
 132
 133        if self.gcms.molecular_search_settings.exploratory_mode:
 134            columns.extend(
 135                [
 136                    "Weighted Cosine Correlation",
 137                    "Cosine Correlation",
 138                    "Stein Scott Similarity",
 139                    "Pearson Correlation",
 140                    "Spearman Correlation",
 141                    "Kendall Tau Correlation",
 142                    "Euclidean Distance",
 143                    "Manhattan Distance",
 144                    "Jaccard Distance",
 145                    "DWT Correlation",
 146                    "DFT Correlation",
 147                ]
 148            )
 149
 150            columns.extend(list(methods_name.values()))
 151
 152        return columns
 153
 154    def get_pandas_df(self, id_label="corems:"):
 155        """Get the exported data as a Pandas DataFrame.
 156
 157        Parameters:
 158        ----------
 159        id_label : str, optional
 160            The ID label for the data. Default is "corems:".
 161
 162        Returns:
 163        -------
 164        DataFrame
 165            The exported data as a Pandas DataFrame.
 166        """
 167
 168        columns = self._init_columns()
 169
 170        dict_data_list = self.get_list_dict_data(self.gcms)
 171
 172        df = DataFrame(dict_data_list, columns=columns)
 173
 174        df.name = self.gcms.sample_name
 175
 176        return df
 177
 178    def get_json(self, nan=False, id_label="corems:"):
 179        """Get the exported data as a JSON string.
 180
 181        Parameters:
 182        ----------
 183        nan : bool, optional
 184            Whether to include NaN values in the JSON string. Default is False.
 185        id_label : str, optional
 186            The ID label for the data. Default is "corems:".
 187
 188        """
 189
 190        import json
 191
 192        dict_data_list = self.get_list_dict_data(self.gcms)
 193
 194        return json.dumps(
 195            dict_data_list, sort_keys=False, indent=4, separators=(",", ": ")
 196        )
 197
 198    def to_pandas(self, write_metadata=True, id_label="corems:"):
 199        """Export the data to a Pandas DataFrame and save it as a pickle file.
 200
 201        Parameters:
 202        ----------
 203        write_metadata : bool, optional
 204            Whether to write metadata to the output file.
 205        id_label : str, optional
 206            The ID label for the data.
 207        """
 208
 209        columns = self._init_columns()
 210
 211        dict_data_list = self.get_list_dict_data(self.gcms)
 212
 213        df = DataFrame(dict_data_list, columns=columns)
 214
 215        df.to_pickle(self.output_file.with_suffix(".pkl"))
 216
 217        if write_metadata:
 218            self.write_settings(
 219                self.output_file.with_suffix(".pkl"), self.gcms, id_label="corems:"
 220            )
 221
 222    def to_excel(self, write_mode="a", write_metadata=True, id_label="corems:"):
 223        """Export the data to an Excel file.
 224
 225        Parameters:
 226        ----------
 227        write_mode : str, optional
 228            The write mode for the Excel file. Default is 'a' (append).
 229        write_metadata : bool, optional
 230            Whether to write metadata to the output file. Default is True.
 231        id_label : str, optional
 232            The ID label for the data. Default is "corems:".
 233        """
 234
 235        out_put_path = self.output_file.with_suffix(".xlsx")
 236
 237        columns = self._init_columns()
 238
 239        dict_data_list = self.get_list_dict_data(self.gcms)
 240
 241        df = DataFrame(dict_data_list, columns=columns)
 242
 243        if write_mode == "a" and out_put_path.exists():
 244            writer = ExcelWriter(out_put_path, engine="openpyxl")
 245            # try to open an existing workbook
 246            writer.book = load_workbook(out_put_path)
 247            # copy existing sheets
 248            writer.sheets = dict((ws.title, ws) for ws in writer.book.worksheets)
 249            # read existing file
 250            reader = read_excel(out_put_path)
 251            # write out the new sheet
 252            df.to_excel(writer, index=False, header=False, startrow=len(reader) + 1)
 253
 254            writer.close()
 255        else:
 256            df.to_excel(
 257                self.output_file.with_suffix(".xlsx"), index=False, engine="openpyxl"
 258            )
 259
 260        if write_metadata:
 261            self.write_settings(out_put_path, self.gcms, id_label=id_label)
 262
 263    def to_csv(
 264        self,
 265        separate_output=False,
 266        write_mode="w",
 267        write_metadata=True,
 268        id_label="corems:",
 269    ):
 270        """Export the data to a CSV file.
 271
 272        Parameters:
 273        ----------
 274        separate_output : bool, optional
 275            Whether to separate the output into multiple files. Default is False.
 276        write_mode : str, optional
 277            The write mode for the CSV file. Default is 'w' (write).
 278        write_metadata : bool, optional
 279            Whether to write metadata to the output file. Default is True.
 280        id_label : str, optional
 281            The ID label for the data. Default is "corems:".
 282        """
 283
 284        if separate_output:
 285            # set write mode to write
 286            # this mode will overwrite the file without warning
 287            write_mode = "w"
 288        else:
 289            # set write mode to append
 290            write_mode = "a"
 291
 292        columns = self._init_columns()
 293
 294        dict_data_list = self.get_list_dict_data(self.gcms)
 295
 296        out_put_path = self.output_file.with_suffix(".csv")
 297
 298        write_header = not out_put_path.exists()
 299
 300        try:
 301            with open(out_put_path, write_mode, newline="") as csvfile:
 302                writer = csv.DictWriter(csvfile, fieldnames=columns)
 303                if write_header:
 304                    writer.writeheader()
 305                for data in dict_data_list:
 306                    writer.writerow(data)
 307
 308            if write_metadata:
 309                self.write_settings(out_put_path, self.gcms, id_label=id_label)
 310
 311        except IOError as ioerror:
 312            print(ioerror)
 313
 314    def to_hdf(self, id_label="corems:"):
 315        """Export the data to an HDF5 file.
 316
 317        Parameters:
 318        ----------
 319        id_label : str, optional
 320            The ID label for the data. Default is "corems:".
 321        """
 322
 323        # save sample at a time
 324        def add_compound(gc_peak, compound_obj):
 325            modifier = compound_obj.classify if compound_obj.classify else ""
 326            compound_group = compound_obj.name.replace("/", "") + " " + modifier
 327
 328            if compound_group not in peak_group:
 329                compound_group = peak_group.create_group(compound_group)
 330
 331                # compound_group.attrs["retention_time"] = compound_obj.retention_time
 332                compound_group.attrs["retention_index"] = compound_obj.ri
 333                compound_group.attrs["retention_index_score"] = compound_obj.ri_score
 334                compound_group.attrs["spectral_similarity_score"] = (
 335                    compound_obj.spectral_similarity_score
 336                )
 337                compound_group.attrs["similarity_score"] = compound_obj.similarity_score
 338
 339                compond_mz = compound_group.create_dataset(
 340                    "mz", data=np.array(compound_obj.mz), dtype="f8"
 341                )
 342                compond_abundance = compound_group.create_dataset(
 343                    "abundance", data=np.array(compound_obj.abundance), dtype="f8"
 344                )
 345
 346                if self.gcms.molecular_search_settings.exploratory_mode:
 347                    compound_group.attrs["Spectral Similarities"] = json.dumps(
 348                        compound_obj.spectral_similarity_scores,
 349                        sort_keys=False,
 350                        indent=4,
 351                        separators=(",", ":"),
 352                    )
 353            else:
 354                warnings.warn("Skipping duplicate reference compound.")
 355
 356        import json
 357        from datetime import datetime, timezone
 358
 359        import h5py
 360        import numpy as np
 361
 362        output_path = self.output_file.with_suffix(".hdf5")
 363
 364        with h5py.File(output_path, "w") as hdf_handle:
 365            timenow = str(datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z"))
 366            hdf_handle.attrs["time_stamp"] = timenow
 367            hdf_handle.attrs["data_structure"] = "gcms"
 368            hdf_handle.attrs["analyzer"] = self.gcms.analyzer
 369            hdf_handle.attrs["instrument_label"] = self.gcms.instrument_label
 370
 371            hdf_handle.attrs["sample_id"] = "self.gcms.id"
 372            hdf_handle.attrs["sample_name"] = self.gcms.sample_name
 373            hdf_handle.attrs["input_data"] = str(self.gcms.file_location)
 374            hdf_handle.attrs["output_data"] = str(output_path)
 375            hdf_handle.attrs["output_data_id"] = id_label + uuid.uuid4().hex
 376            hdf_handle.attrs["corems_version"] = __version__
 377
 378            hdf_handle.attrs["Stats"] = json.dumps(
 379                self.get_data_stats(self.gcms),
 380                sort_keys=False,
 381                indent=4,
 382                separators=(",", ": "),
 383            )
 384            hdf_handle.attrs["Calibration"] = json.dumps(
 385                self.get_calibration_stats(self.gcms, id_label),
 386                sort_keys=False,
 387                indent=4,
 388                separators=(",", ": "),
 389            )
 390            hdf_handle.attrs["Blank"] = json.dumps(
 391                self.get_blank_stats(self.gcms),
 392                sort_keys=False,
 393                indent=4,
 394                separators=(",", ": "),
 395            )
 396
 397            corems_dict_setting = parameter_to_dict.get_dict_data_gcms(self.gcms)
 398            hdf_handle.attrs["CoreMSParameters"] = json.dumps(
 399                corems_dict_setting, sort_keys=False, indent=4, separators=(",", ": ")
 400            )
 401
 402            scans_dataset = hdf_handle.create_dataset(
 403                "scans", data=np.array(self.gcms.scans_number), dtype="f8"
 404            )
 405            rt_dataset = hdf_handle.create_dataset(
 406                "rt", data=np.array(self.gcms.retention_time), dtype="f8"
 407            )
 408            tic_dataset = hdf_handle.create_dataset(
 409                "tic", data=np.array(self.gcms.tic), dtype="f8"
 410            )
 411            processed_tic_dataset = hdf_handle.create_dataset(
 412                "processed_tic", data=np.array(self.gcms.processed_tic), dtype="f8"
 413            )
 414
 415            output_score_method = (
 416                self.gcms.molecular_search_settings.output_score_method
 417            )
 418
 419            for gc_peak in self.gcms:
 420                # print(gc_peak.retention_time)
 421                # print(gc_peak.tic)
 422
 423                # check if there is a compound candidate
 424                peak_group = hdf_handle.create_group(str(gc_peak.retention_time))
 425                peak_group.attrs["deconvolution"] = int(
 426                    self.gcms.chromatogram_settings.use_deconvolution
 427                )
 428
 429                peak_group.attrs["start_scan"] = gc_peak.start_scan
 430                peak_group.attrs["apex_scan"] = gc_peak.apex_scan
 431                peak_group.attrs["final_scan"] = gc_peak.final_scan
 432
 433                peak_group.attrs["retention_index"] = gc_peak.ri
 434                peak_group.attrs["retention_time"] = gc_peak.retention_time
 435                peak_group.attrs["area"] = gc_peak.area
 436
 437                mz = peak_group.create_dataset(
 438                    "mz", data=np.array(gc_peak.mass_spectrum.mz_exp), dtype="f8"
 439                )
 440                abundance = peak_group.create_dataset(
 441                    "abundance",
 442                    data=np.array(gc_peak.mass_spectrum.abundance),
 443                    dtype="f8",
 444                )
 445
 446                if gc_peak:
 447                    if output_score_method == "highest_sim_score":
 448                        compound_obj = gc_peak.highest_score_compound
 449                        add_compound(gc_peak, compound_obj)
 450
 451                    elif output_score_method == "highest_ss":
 452                        compound_obj = gc_peak.highest_ss_compound
 453                        add_compound(gc_peak, compound_obj)
 454
 455                    else:
 456                        for compound_obj in gc_peak:
 457                            add_compound(gc_peak, compound_obj)
 458
 459    def get_data_stats(self, gcms):
 460        """Get statistics about the GCMS data.
 461
 462        Parameters:
 463        ----------
 464        gcms : object
 465            The low resolution GCMS object.
 466
 467        Returns:
 468        -------
 469        dict
 470            A dictionary containing the data statistics.
 471        """
 472
 473        matched_peaks = gcms.matched_peaks
 474        no_matched_peaks = gcms.no_matched_peaks
 475        unique_metabolites = gcms.unique_metabolites
 476
 477        peak_matchs_above_0p85 = 0
 478        unique_peak_match_above_0p85 = 0
 479        for match_peak in matched_peaks:
 480            gc_peak_above_85 = 0
 481            matches_above_85 = list(
 482                filter(lambda m: m.similarity_score >= 0.85, match_peak)
 483            )
 484            if matches_above_85:
 485                peak_matchs_above_0p85 += 1
 486            if len(matches_above_85) == 1:
 487                unique_peak_match_above_0p85 += 1
 488
 489        data_stats = {}
 490        data_stats["average_signal_noise"] = "ni"
 491        data_stats["chromatogram_dynamic_range"] = gcms.dynamic_range
 492        data_stats["total_number_peaks"] = len(gcms)
 493        data_stats["total_peaks_matched"] = len(matched_peaks)
 494        data_stats["total_peaks_without_matches"] = len(no_matched_peaks)
 495        data_stats["total_matches_above_similarity_score_0.85"] = peak_matchs_above_0p85
 496        data_stats["single_matches_above_similarity_score_0.85"] = (
 497            unique_peak_match_above_0p85
 498        )
 499        data_stats["unique_metabolites"] = len(unique_metabolites)
 500
 501        return data_stats
 502
 503    def get_calibration_stats(self, gcms, id_label):
 504        """Get statistics about the GC-MS calibration.
 505
 506        Parameters:
 507        ----------
 508        """
 509        calibration_parameters = {}
 510
 511        calibration_parameters["calibration_rt_ri_pairs_ref"] = gcms.ri_pairs_ref
 512        calibration_parameters["data_url"] = str(gcms.cal_file_path)
 513        calibration_parameters["has_input"] = id_label + corems_md5(gcms.cal_file_path)
 514        calibration_parameters["data_name"] = str(gcms.cal_file_path.stem)
 515        calibration_parameters["calibration_method"] = ""
 516
 517        return calibration_parameters
 518
 519    def get_blank_stats(self, gcms):
 520        """Get statistics about the GC-MS blank."""
 521        blank_parameters = {}
 522
 523        blank_parameters["data_name"] = "ni"
 524        blank_parameters["blank_id"] = "ni"
 525        blank_parameters["data_url"] = "ni"
 526        blank_parameters["has_input"] = "ni"
 527        blank_parameters["common_features_to_blank"] = "ni"
 528
 529        return blank_parameters
 530
 531    def get_instrument_metadata(self, gcms):
 532        """Get metadata about the GC-MS instrument."""
 533        instrument_metadata = {}
 534
 535        instrument_metadata["analyzer"] = gcms.analyzer
 536        instrument_metadata["instrument_label"] = gcms.instrument_label
 537        instrument_metadata["instrument_id"] = uuid.uuid4().hex
 538
 539        return instrument_metadata
 540
 541    def get_data_metadata(self, gcms, id_label, output_path):
 542        """Get metadata about the GC-MS data.
 543
 544        Parameters:
 545        ----------
 546        gcms : object
 547            The low resolution GCMS object.
 548        id_label : str
 549            The ID label for the data.
 550        output_path : str
 551            The output file path.
 552
 553        Returns:
 554        -------
 555        dict
 556            A dictionary containing the data metadata.
 557        """
 558        if isinstance(output_path, str):
 559            output_path = Path(output_path)
 560
 561        paramaters_path = output_path.with_suffix(".json")
 562
 563        if paramaters_path.exists():
 564            with paramaters_path.open() as current_param:
 565                metadata = json.load(current_param)
 566                data_metadata = metadata.get("Data")
 567        else:
 568            data_metadata = {}
 569            data_metadata["data_name"] = []
 570            data_metadata["input_data_url"] = []
 571            data_metadata["has_input"] = []
 572
 573        data_metadata["data_name"].append(gcms.sample_name)
 574        data_metadata["input_data_url"].append(str(gcms.file_location))
 575        data_metadata["has_input"].append(id_label + corems_md5(gcms.file_location))
 576
 577        data_metadata["output_data_name"] = str(output_path.stem)
 578        data_metadata["output_data_url"] = str(output_path)
 579        data_metadata["has_output"] = id_label + corems_md5(output_path)
 580
 581        return data_metadata
 582
 583    def get_parameters_json(self, gcms, id_label, output_path):
 584        """Get the parameters as a JSON string.
 585
 586        Parameters:
 587        ----------
 588        gcms : GCMS object
 589            The low resolution GCMS object.
 590        id_label : str
 591            The ID label for the data.
 592        output_path : str
 593            The output file path.
 594
 595        Returns:
 596        -------
 597        str
 598            The parameters as a JSON string.
 599        """
 600
 601        output_parameters_dict = {}
 602        output_parameters_dict["Data"] = self.get_data_metadata(
 603            gcms, id_label, output_path
 604        )
 605        output_parameters_dict["Stats"] = self.get_data_stats(gcms)
 606        output_parameters_dict["Calibration"] = self.get_calibration_stats(
 607            gcms, id_label
 608        )
 609        output_parameters_dict["Blank"] = self.get_blank_stats(gcms)
 610        output_parameters_dict["Instrument"] = self.get_instrument_metadata(gcms)
 611        corems_dict_setting = parameter_to_dict.get_dict_data_gcms(gcms)
 612        corems_dict_setting["corems_version"] = __version__
 613        output_parameters_dict["CoreMSParameters"] = corems_dict_setting
 614        output_parameters_dict["has_metabolite"] = gcms.metabolites_data
 615        output = json.dumps(
 616            output_parameters_dict, sort_keys=False, indent=4, separators=(",", ": ")
 617        )
 618
 619        return output
 620
 621    def write_settings(self, output_path, gcms, id_label="emsl:"):
 622        """Write the settings to a JSON file.
 623
 624        Parameters:
 625        ----------
 626        output_path : str
 627            The output file path.
 628        gcms : GCMS object
 629            The low resolution GCMS object.
 630        id_label : str
 631            The ID label for the data. Default is "emsl:".
 632
 633        """
 634
 635        output = self.get_parameters_json(gcms, id_label, output_path)
 636
 637        with open(
 638            output_path.with_suffix(".json"),
 639            "w",
 640            encoding="utf8",
 641        ) as outfile:
 642            outfile.write(output)
 643
 644    def get_list_dict_data(self, gcms, include_no_match=True, no_match_inline=False):
 645        """Get the exported data as a list of dictionaries.
 646
 647        Parameters:
 648        ----------
 649        gcms : object
 650            The low resolution GCMS object.
 651        include_no_match : bool, optional
 652            Whether to include no match data. Default is True.
 653        no_match_inline : bool, optional
 654            Whether to include no match data inline. Default is False.
 655
 656        Returns:
 657        -------
 658        list
 659            The exported data as a list of dictionaries.
 660        """
 661
 662        output_score_method = gcms.molecular_search_settings.output_score_method
 663
 664        dict_data_list = []
 665
 666        def add_match_dict_data():
 667            derivatization = "{}:{}:{}".format(
 668                compound_obj.classify,
 669                compound_obj.derivativenum,
 670                compound_obj.derivatization,
 671            )
 672            out_dict = {
 673                "Sample name": gcms.sample_name,
 674                "Peak Index": gcpeak_index,
 675                "Retention Time": gc_peak.retention_time,
 676                "Retention Time Ref": compound_obj.retention_time,
 677                "Peak Height": gc_peak.tic,
 678                "Peak Area": gc_peak.area,
 679                "Retention index": gc_peak.ri,
 680                "Retention index Ref": compound_obj.ri,
 681                "Retention Index Score": compound_obj.ri_score,
 682                "Spectral Similarity Score": compound_obj.spectral_similarity_score,
 683                "Similarity Score": compound_obj.similarity_score,
 684                "Compound Name": compound_obj.name,
 685                "Chebi ID": compound_obj.metadata.chebi,
 686                "Kegg Compound ID": compound_obj.metadata.kegg,
 687                "Inchi": compound_obj.metadata.inchi,
 688                "Inchi Key": compound_obj.metadata.inchikey,
 689                "Smiles": compound_obj.metadata.smiles,
 690                "Molecular Formula": compound_obj.formula,
 691                "IUPAC Name": compound_obj.metadata.iupac_name,
 692                "Traditional Name": compound_obj.metadata.traditional_name,
 693                "Common Name": compound_obj.metadata.common_name,
 694                "Derivatization": derivatization,
 695            }
 696
 697            if self.gcms.molecular_search_settings.exploratory_mode:
 698                out_dict.update(
 699                    {
 700                        "Weighted Cosine Correlation": compound_obj.spectral_similarity_scores.get(
 701                            "weighted_cosine_correlation"
 702                        ),
 703                        "Cosine Correlation": compound_obj.spectral_similarity_scores.get(
 704                            "cosine_correlation"
 705                        ),
 706                        "Stein Scott Similarity": compound_obj.spectral_similarity_scores.get(
 707                            "stein_scott_similarity"
 708                        ),
 709                        "Pearson Correlation": compound_obj.spectral_similarity_scores.get(
 710                            "pearson_correlation"
 711                        ),
 712                        "Spearman Correlation": compound_obj.spectral_similarity_scores.get(
 713                            "spearman_correlation"
 714                        ),
 715                        "Kendall Tau Correlation": compound_obj.spectral_similarity_scores.get(
 716                            "kendall_tau_correlation"
 717                        ),
 718                        "DFT Correlation": compound_obj.spectral_similarity_scores.get(
 719                            "dft_correlation"
 720                        ),
 721                        "DWT Correlation": compound_obj.spectral_similarity_scores.get(
 722                            "dwt_correlation"
 723                        ),
 724                        "Euclidean Distance": compound_obj.spectral_similarity_scores.get(
 725                            "euclidean_distance"
 726                        ),
 727                        "Manhattan Distance": compound_obj.spectral_similarity_scores.get(
 728                            "manhattan_distance"
 729                        ),
 730                        "Jaccard Distance": compound_obj.spectral_similarity_scores.get(
 731                            "jaccard_distance"
 732                        ),
 733                    }
 734                )
 735                for method in methods_name:
 736                    out_dict[methods_name.get(method)] = (
 737                        compound_obj.spectral_similarity_scores.get(method)
 738                    )
 739
 740            dict_data_list.append(out_dict)
 741
 742        def add_no_match_dict_data():
 743            dict_data_list.append(
 744                {
 745                    "Sample name": gcms.sample_name,
 746                    "Peak Index": gcpeak_index,
 747                    "Retention Time": gc_peak.retention_time,
 748                    "Peak Height": gc_peak.tic,
 749                    "Peak Area": gc_peak.area,
 750                    "Retention index": gc_peak.ri,
 751                }
 752            )
 753
 754        for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
 755            # check if there is a compound candidate
 756            if gc_peak:
 757                if output_score_method == "highest_sim_score":
 758                    compound_obj = gc_peak.highest_score_compound
 759                    add_match_dict_data()
 760
 761                elif output_score_method == "highest_ss":
 762                    compound_obj = gc_peak.highest_ss_compound
 763                    add_match_dict_data()
 764
 765                else:
 766                    for compound_obj in gc_peak:
 767                        add_match_dict_data()  # add monoisotopic peak
 768
 769            else:
 770                # include not_match
 771                if include_no_match and no_match_inline:
 772                    add_no_match_dict_data()
 773
 774        if include_no_match and not no_match_inline:
 775            for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
 776                if not gc_peak:
 777                    add_no_match_dict_data()
 778
 779        return dict_data_list
 780
 781
 782class HighResMassSpectraExport(HighResMassSpecExport):
 783    """A class to export high resolution mass spectra data.
 784
 785    This class provides methods to export high resolution mass spectra data to various formats
 786    such as Excel, CSV, HDF5, and Pandas DataFrame.
 787
 788    Parameters
 789    ----------
 790    out_file_path : str | Path
 791        The output file path.
 792    mass_spectra : object
 793        The high resolution mass spectra object.
 794    output_type : str, optional
 795        The output type. Default is 'excel'.
 796
 797    Attributes
 798    ----------
 799    output_file : Path
 800        The output file path without suffix
 801    dir_loc : Path
 802        The directory location for the output file,
 803        by default this will be the output_file + ".corems" and all output files will be
 804        written into this location
 805    mass_spectra : MassSpectraBase
 806        The high resolution mass spectra object.
 807    """
 808
 809    def __init__(self, out_file_path, mass_spectra, output_type="excel"):
 810        super().__init__(
 811            out_file_path=out_file_path, mass_spectrum=None, output_type=output_type
 812        )
 813
 814        self.dir_loc = Path(out_file_path + ".corems")
 815        self.dir_loc.mkdir(exist_ok=True)
 816        # Place the output file in the directory
 817        self.output_file = self.dir_loc / Path(out_file_path).name
 818        self._output_type = output_type  # 'excel', 'csv', 'pandas' or 'hdf5'
 819        self.mass_spectra = mass_spectra
 820        self.atoms_order_list = None
 821        self._init_columns()
 822
 823    def get_pandas_df(self):
 824        """Get the mass spectra as a list of Pandas DataFrames."""
 825
 826        list_df = []
 827
 828        for mass_spectrum in self.mass_spectra:
 829            columns = self.columns_label + self.get_all_used_atoms_in_order(
 830                mass_spectrum
 831            )
 832
 833            dict_data_list = self.get_list_dict_data(mass_spectrum)
 834
 835            df = DataFrame(dict_data_list, columns=columns)
 836
 837            scan_number = mass_spectrum.scan_number
 838
 839            df.name = str(self.output_file) + "_" + str(scan_number)
 840
 841            list_df.append(df)
 842
 843        return list_df
 844
 845    def to_pandas(self, write_metadata=True):
 846        """Export the data to a Pandas DataFrame and save it as a pickle file.
 847
 848        Parameters:
 849        ----------
 850        write_metadata : bool, optional
 851            Whether to write metadata to the output file. Default is True.
 852        """
 853
 854        for mass_spectrum in self.mass_spectra:
 855            columns = self.columns_label + self.get_all_used_atoms_in_order(
 856                mass_spectrum
 857            )
 858
 859            dict_data_list = self.get_list_dict_data(mass_spectrum)
 860
 861            df = DataFrame(dict_data_list, columns=columns)
 862
 863            scan_number = mass_spectrum.scan_number
 864
 865            out_filename = Path(
 866                "%s_scan%s%s" % (self.output_file, str(scan_number), ".pkl")
 867            )
 868
 869            df.to_pickle(self.dir_loc / out_filename)
 870
 871            if write_metadata:
 872                self.write_settings(
 873                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 874                )
 875
 876    def to_excel(self, write_metadata=True):
 877        """Export the data to an Excel file.
 878
 879        Parameters:
 880        ----------
 881        write_metadata : bool, optional
 882            Whether to write metadata to the output file. Default is True.
 883        """
 884        for mass_spectrum in self.mass_spectra:
 885            columns = self.columns_label + self.get_all_used_atoms_in_order(
 886                mass_spectrum
 887            )
 888
 889            dict_data_list = self.get_list_dict_data(mass_spectrum)
 890
 891            df = DataFrame(dict_data_list, columns=columns)
 892
 893            scan_number = mass_spectrum.scan_number
 894
 895            out_filename = Path(
 896                "%s_scan%s%s" % (self.output_file, str(scan_number), ".xlsx")
 897            )
 898
 899            df.to_excel(self.dir_loc / out_filename)
 900
 901            if write_metadata:
 902                self.write_settings(
 903                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 904                )
 905
 906    def to_csv(self, write_metadata=True):
 907        """Export the data to a CSV file.
 908
 909        Parameters:
 910        ----------
 911        write_metadata : bool, optional
 912            Whether to write metadata to the output file. Default is True.
 913        """
 914        import csv
 915
 916        for mass_spectrum in self.mass_spectra:
 917            columns = self.columns_label + self.get_all_used_atoms_in_order(
 918                mass_spectrum
 919            )
 920
 921            scan_number = mass_spectrum.scan_number
 922
 923            dict_data_list = self.get_list_dict_data(mass_spectrum)
 924
 925            out_filename = Path(
 926                "%s_scan%s%s" % (self.output_file, str(scan_number), ".csv")
 927            )
 928
 929            with open(self.dir_loc / out_filename, "w", newline="") as csvfile:
 930                writer = csv.DictWriter(csvfile, fieldnames=columns)
 931                writer.writeheader()
 932                for data in dict_data_list:
 933                    writer.writerow(data)
 934
 935            if write_metadata:
 936                self.write_settings(
 937                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 938                )
 939
 940    def get_mass_spectra_attrs(self):
 941        """Get the mass spectra attributes as a JSON string.
 942
 943        Parameters:
 944        ----------
 945        mass_spectra : object
 946            The high resolution mass spectra object.
 947
 948        Returns:
 949        -------
 950        str
 951            The mass spectra attributes as a JSON string.
 952        """
 953        dict_ms_attrs = {}
 954        dict_ms_attrs["analyzer"] = self.mass_spectra.analyzer
 955        dict_ms_attrs["instrument_label"] = self.mass_spectra.instrument_label
 956        dict_ms_attrs["sample_name"] = self.mass_spectra.sample_name
 957
 958        return json.dumps(
 959            dict_ms_attrs, sort_keys=False, indent=4, separators=(",", ": ")
 960        )
 961
 962    def to_hdf(self, overwrite=False, export_raw=True):
 963        """Export the data to an HDF5 file.
 964
 965        Parameters
 966        ----------
 967        overwrite : bool, optional
 968            Whether to overwrite the output file. Default is False.
 969        export_raw : bool, optional
 970            Whether to export the raw mass spectra data. Default is True.
 971        """
 972        if overwrite:
 973            if self.output_file.with_suffix(".hdf5").exists():
 974                self.output_file.with_suffix(".hdf5").unlink()
 975
 976        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
 977            if not hdf_handle.attrs.get("date_utc"):
 978                # Set metadata for all mass spectra
 979                timenow = str(
 980                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
 981                )
 982                hdf_handle.attrs["date_utc"] = timenow
 983                hdf_handle.attrs["filename"] = self.mass_spectra.file_location.name
 984                hdf_handle.attrs["data_structure"] = "mass_spectra"
 985                hdf_handle.attrs["analyzer"] = self.mass_spectra.analyzer
 986                hdf_handle.attrs["instrument_label"] = (
 987                    self.mass_spectra.instrument_label
 988                )
 989                hdf_handle.attrs["sample_name"] = self.mass_spectra.sample_name
 990                hdf_handle.attrs["polarity"] = self.mass_spectra.polarity
 991                hdf_handle.attrs["parser_type"] = (
 992                    self.mass_spectra.spectra_parser_class.__name__
 993                )
 994                hdf_handle.attrs["original_file_location"] = (
 995                    self.mass_spectra.file_location._str
 996                )
 997                
 998                # Save creation time from original parser if available
 999                try:
1000                    if hasattr(self.mass_spectra, 'spectra_parser') and self.mass_spectra.spectra_parser is not None:
1001                        creation_time = self.mass_spectra.spectra_parser.get_creation_time()
1002                        if creation_time is not None:
1003                            hdf_handle.attrs["creation_time"] = creation_time.isoformat()
1004                except Exception:
1005                    pass  # If creation time cannot be retrieved, skip it
1006
1007            if "mass_spectra" not in hdf_handle:
1008                mass_spectra_group = hdf_handle.create_group("mass_spectra")
1009            else:
1010                mass_spectra_group = hdf_handle.get("mass_spectra")
1011
1012            for mass_spectrum in self.mass_spectra:
1013                group_key = str(int(mass_spectrum.scan_number))
1014
1015                self.add_mass_spectrum_to_hdf5(
1016                    hdf_handle, mass_spectrum, group_key, mass_spectra_group, export_raw
1017                )
1018
1019
1020class LCMSExport(HighResMassSpectraExport):
1021    """A class to export high resolution LC-MS data.
1022
1023    This class provides methods to export high resolution LC-MS data to HDF5.
1024
1025    Parameters
1026    ----------
1027    out_file_path : str | Path
1028        The output file path, do not include the file extension.
1029    lcms_object : LCMSBase
1030        The high resolution lc-ms object.
1031    """
1032
1033    def __init__(self, out_file_path, mass_spectra):
1034        super().__init__(out_file_path, mass_spectra, output_type="hdf5")
1035
1036    @staticmethod
1037    def _save_mass_features_dict_to_hdf5(mass_features_dict, mass_features_group, overwrite=False):
1038        """Save a dictionary of mass features to an HDF5 group.
1039        
1040        This is a helper method that can be reused by different export classes.
1041        
1042        Parameters
1043        ----------
1044        mass_features_dict : dict
1045            Dictionary of mass features to save, keyed by mass feature ID.
1046        mass_features_group : h5py.Group
1047            The HDF5 group to save the mass features to.
1048        overwrite : bool, optional
1049            Whether to overwrite existing mass features. Default is False.
1050        """
1051
1052        # Create group for each mass feature, with key as the mass feature id
1053        for k, v in mass_features_dict.items():
1054                if str(k) not in mass_features_group or overwrite:
1055                    if str(k) in mass_features_group and overwrite:
1056                        del mass_features_group[str(k)]
1057                    mass_features_group.create_group(str(k))
1058                    # Loop through each of the mass feature attributes and add them as attributes (if single value) or datasets (if array)
1059                    for k2, v2 in v.__dict__.items():
1060                        if v2 is not None:
1061                            # Check if the attribute is an integer or float and set as an attribute in the mass feature group
1062                            if k2 not in [
1063                                "chromatogram_parent",
1064                                "ms2_mass_spectra",
1065                                "mass_spectrum",
1066                                "_eic_data",
1067                                "ms2_similarity_results",
1068                            ]:
1069                                if k2 == "ms2_scan_numbers":
1070                                    array = np.array(v2)
1071                                    # Convert int64 to int32
1072                                    if array.dtype == np.int64:
1073                                        array = array.astype(np.int32)
1074                                    mass_features_group[str(k)].create_dataset(
1075                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1076                                    )
1077                                elif k2 == "_half_height_width":
1078                                    array = np.array(v2)
1079                                    # Convert float64 to float32
1080                                    if array.dtype == np.float64:
1081                                        array = array.astype(np.float32)
1082                                    mass_features_group[str(k)].create_dataset(
1083                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1084                                    )
1085                                elif k2 == "_ms_deconvoluted_idx":
1086                                    array = np.array(v2)
1087                                    # Convert int64 to int32
1088                                    if array.dtype == np.int64:
1089                                        array = array.astype(np.int32)
1090                                    mass_features_group[str(k)].create_dataset(
1091                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1092                                    )
1093                                elif k2 == "associated_mass_features_deconvoluted":
1094                                    array = np.array(v2)
1095                                    # Convert int64 to int32
1096                                    if array.dtype == np.int64:
1097                                        array = array.astype(np.int32)
1098                                    mass_features_group[str(k)].create_dataset(
1099                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1100                                    )
1101                                elif k2 == "_noise_score":
1102                                    array = np.array(v2)
1103                                    # Convert float64 to float32
1104                                    if array.dtype == np.float64:
1105                                        array = array.astype(np.float32)
1106                                    mass_features_group[str(k)].create_dataset(
1107                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1108                                    )
1109                                elif (
1110                                    isinstance(v2, int)
1111                                    or isinstance(v2, float)
1112                                    or isinstance(v2, str)
1113                                    or isinstance(v2, np.integer)
1114                                    or isinstance(v2, np.float32)
1115                                    or isinstance(v2, np.float64)
1116                                    or isinstance(v2, np.bool_)
1117                                ):
1118                                    # Convert numpy types to smaller precision for storage
1119                                    if isinstance(v2, np.int64):
1120                                        v2 = np.int32(v2)
1121                                    elif isinstance(v2, np.float64):
1122                                        v2 = np.float32(v2)
1123                                    mass_features_group[str(k)].attrs[str(k2)] = v2
1124    
1125    @staticmethod
1126    def _save_eics_dict_to_hdf5(eics_dict, eics_group, overwrite=False):
1127        """Save a dictionary of EICs to an HDF5 group.
1128        
1129        This is a static helper method that can be reused by different export classes
1130        to save EIC data in a consistent format.
1131        
1132        Parameters
1133        ----------
1134        eics_dict : dict
1135            Dictionary of EIC_Data objects, keyed by m/z value.
1136        eics_group : h5py.Group
1137            The HDF5 group to save the EICs to.
1138        overwrite : bool, optional
1139            Whether to overwrite existing EICs. Default is False.
1140        """
1141        for mz, eic_data in eics_dict.items():
1142            mz_str = str(mz)
1143            if mz_str not in eics_group or overwrite:
1144                if mz_str in eics_group and overwrite:
1145                    del eics_group[mz_str]
1146                eic_grp = eics_group.create_group(mz_str)
1147                eic_grp.attrs["mz"] = mz
1148                
1149                # Save all EIC_Data attributes as datasets
1150                for attr_name, attr_value in eic_data.__dict__.items():
1151                    if attr_value is not None:
1152                        array = np.array(attr_value)
1153                        # Apply data type optimization and compression
1154                        if array.dtype == np.int64:
1155                            array = array.astype(np.int32)
1156                        elif array.dtype == np.float64:
1157                            array = array.astype(np.float32)
1158                        elif array.dtype.str[0:2] == "<U":
1159                            # Convert Unicode strings to UTF-8 encoded strings
1160                            string_data = [str(item) for item in array]
1161                            string_dtype = h5py.string_dtype(encoding='utf-8')
1162                            eic_grp.create_dataset(str(attr_name), data=string_data, dtype=string_dtype, compression="gzip", compression_opts=9, chunks=True)
1163                            continue
1164                        eic_grp.create_dataset(str(attr_name), data=array, compression="gzip", compression_opts=9, chunks=True)
1165    
1166    def _save_mass_features_to_hdf5(self, hdf_handle, group_name = "mass_features", overwrite=False):
1167        """Save the mass features to the HDF5 file.
1168
1169        Parameters
1170        ----------
1171        hdf_handle : h5py.File
1172            The HDF5 file handle.
1173        group_name : str, optional
1174            The name of the group to save the mass features to. Default is 'mass_features'.
1175        overwrite : bool, optional
1176            Whether to overwrite the group if it exists. Default is False.
1177        """
1178        # Determine which mass features to save based on group_name
1179        if group_name == "induced_mass_features":
1180            if len(self.mass_spectra.induced_mass_features) == 0:
1181                return  # No induced mass features to save
1182            mass_features_dict = self.mass_spectra.induced_mass_features
1183        else:
1184            if len(self.mass_spectra.mass_features) == 0:
1185                return  # No mass features to save
1186            mass_features_dict = self.mass_spectra.mass_features
1187
1188        # Add LCMS mass features to hdf5 file
1189        if group_name not in hdf_handle:
1190            mass_features_group = hdf_handle.create_group(group_name)
1191        else:
1192            mass_features_group = hdf_handle.get(group_name)
1193        
1194        # Use the static helper method to save the mass features
1195        self._save_mass_features_dict_to_hdf5(mass_features_dict, mass_features_group, overwrite)
1196
1197    def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml"):
1198        """Export the data to an HDF5.
1199
1200        Parameters
1201        ----------
1202        overwrite : bool, optional
1203            Whether to overwrite the output file. Default is False.
1204        save_parameters : bool, optional
1205            Whether to save the parameters as a separate json or toml file. Default is True.
1206        parameter_format : str, optional
1207            The format to save the parameters in. Default is 'toml'.
1208
1209        Raises
1210        ------
1211        ValueError
1212            If parameter_format is not 'json' or 'toml'.
1213        """
1214        export_profile_spectra = (
1215            self.mass_spectra.parameters.lc_ms.export_profile_spectra
1216        )
1217
1218        # Write the mass spectra data to the hdf5 file
1219        super().to_hdf(overwrite=overwrite, export_raw=export_profile_spectra)
1220
1221        # Write scan info, ms_unprocessed, mass features, eics, and ms2_search results to the hdf5 file
1222        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
1223            # Add scan_info to hdf5 file
1224            if "scan_info" not in hdf_handle or overwrite:
1225                if "scan_info" in hdf_handle and overwrite:
1226                    del hdf_handle["scan_info"]
1227                scan_info_group = hdf_handle.create_group("scan_info")
1228                for k, v in self.mass_spectra._scan_info.items():
1229                    array = np.array(list(v.values()))
1230                    if array.dtype.str[0:2] == "<U":
1231                        array = array.astype("S")
1232                    scan_info_group.create_dataset(k, data=array)
1233
1234            # Add ms_unprocessed to hdf5 file
1235            export_unprocessed_ms1 = (
1236                self.mass_spectra.parameters.lc_ms.export_unprocessed_ms1
1237            )
1238            if self.mass_spectra._ms_unprocessed and export_unprocessed_ms1:
1239                if "ms_unprocessed" not in hdf_handle or overwrite:
1240                    if "ms_unprocessed" in hdf_handle and overwrite:
1241                        del hdf_handle["ms_unprocessed"]
1242                    ms_unprocessed_group = hdf_handle.create_group("ms_unprocessed")
1243                else:
1244                    ms_unprocessed_group = hdf_handle.get("ms_unprocessed")
1245                for k, v in self.mass_spectra._ms_unprocessed.items():
1246                    if str(k) not in ms_unprocessed_group or overwrite:
1247                        if str(k) in ms_unprocessed_group and overwrite:
1248                            del ms_unprocessed_group[str(k)]
1249                        array = np.array(v)
1250                        ms_unprocessed_group.create_dataset(str(k), data=array)
1251
1252            # Add LCMS mass features to hdf5 file
1253            self._save_mass_features_to_hdf5(hdf_handle, group_name="mass_features", overwrite=overwrite)
1254            self._save_mass_features_to_hdf5(hdf_handle, group_name="induced_mass_features", overwrite=overwrite)
1255
1256            # Add EIC data to hdf5 file
1257            export_eics = self.mass_spectra.parameters.lc_ms.export_eics
1258            if len(self.mass_spectra.eics) > 0 and export_eics:
1259                if "eics" not in hdf_handle or overwrite:
1260                    if "eics" in hdf_handle and overwrite:
1261                        del hdf_handle["eics"]
1262                    eic_group = hdf_handle.create_group("eics")
1263                else:
1264                    eic_group = hdf_handle.get("eics")
1265
1266                # Use the static helper method to save the EICs
1267                self._save_eics_dict_to_hdf5(self.mass_spectra.eics, eic_group, overwrite)
1268
1269            # Add ms2_search results to hdf5 file (parameterized)
1270            if len(self.mass_spectra.spectral_search_results) > 0:
1271                if "spectral_search_results" not in hdf_handle or overwrite:
1272                    if "spectral_search_results" in hdf_handle and overwrite:
1273                        del hdf_handle["spectral_search_results"]
1274                    spectral_search_results = hdf_handle.create_group(
1275                        "spectral_search_results"
1276                    )
1277                else:
1278                    spectral_search_results = hdf_handle.get("spectral_search_results")
1279                # Create group for each search result by ms2_scan / precursor_mz
1280                for k, v in self.mass_spectra.spectral_search_results.items():
1281                    #TODO KRH: Fix to handle if export_only_relevant and k not in relevant_scan_numbers: continue!
1282                    if str(k) not in spectral_search_results or overwrite:
1283                        if str(k) in spectral_search_results and overwrite:
1284                            del spectral_search_results[str(k)]
1285                        spectral_search_results.create_group(str(k))
1286                        for k2, v2 in v.items():
1287                            spectral_search_results[str(k)].create_group(str(k2))
1288                            spectral_search_results[str(k)][str(k2)].attrs[
1289                                "precursor_mz"
1290                            ] = v2.precursor_mz
1291                            spectral_search_results[str(k)][str(k2)].attrs[
1292                                "query_spectrum_id"
1293                            ] = v2.query_spectrum_id
1294                            # Loop through each of the attributes and add them as datasets (if array)
1295                            for k3, v3 in v2.__dict__.items():
1296                                if v3 is not None and k3 not in [
1297                                    "query_spectrum",
1298                                    "precursor_mz",
1299                                    "query_spectrum_id",
1300                                ]:
1301                                    if k3 == "query_frag_types" or k3 == "ref_frag_types":
1302                                        v3 = [", ".join(x) for x in v3]
1303                                    if all(v3 is not None for v3 in v3):
1304                                        array = np.array(v3)
1305                                    if array.dtype.str[0:2] == "<U":
1306                                        array = array.astype("S")
1307                                    spectral_search_results[str(k)][str(k2)].create_dataset(
1308                                        str(k3), data=array
1309                                    )
1310
1311            # Save parameters as separate json
1312            if save_parameters:
1313                # Check if parameter_format is valid
1314                if parameter_format not in ["json", "toml"]:
1315                    raise ValueError("parameter_format must be 'json' or 'toml'")
1316
1317                if parameter_format == "json":
1318                    dump_lcms_settings_json(
1319                        filename=self.output_file.with_suffix(".json"),
1320                        lcms_obj=self.mass_spectra,
1321                    )
1322                elif parameter_format == "toml":
1323                    dump_lcms_settings_toml(
1324                        filename=self.output_file.with_suffix(".toml"),
1325                        lcms_obj=self.mass_spectra,
1326                    )
1327
1328class LCMSMetabolomicsExport(LCMSExport):
1329    """A class to export LCMS metabolite data.
1330
1331    This class provides methods to export LCMS metabolite data to various formats and summarize the metabolite report.
1332
1333    Parameters
1334    ----------
1335    out_file_path : str | Path
1336        The output file path, do not include the file extension.
1337    mass_spectra : object
1338        The high resolution mass spectra object.
1339    """
1340
1341    def __init__(self, out_file_path, mass_spectra):
1342        super().__init__(out_file_path, mass_spectra)
1343        self.ion_type_dict = ion_type_dict
1344    
1345    @staticmethod
1346    def get_ion_formula(neutral_formula, ion_type):
1347        """From a neutral formula and an ion type, return the formula of the ion.
1348
1349        Notes
1350        -----
1351        This is a static method.
1352        If the neutral_formula is not a string, this method will return None.
1353
1354        Parameters
1355        ----------
1356        neutral_formula : str
1357            The neutral formula, this should be a string form from the MolecularFormula class
1358            (e.g. 'C2 H4 O2', isotopes OK), or simple string (e.g. 'C2H4O2', no isotope handling in this case).
1359            In the case of a simple string, the atoms are parsed based on the presence of capital letters,
1360            e.g. MgCl2 is parsed as 'Mg Cl2.
1361        ion_type : str
1362            The ion type, e.g. 'protonated', '[M+H]+', '[M+Na]+', etc.
1363            See the self.ion_type_dict for the available ion types.
1364
1365        Returns
1366        -------
1367        str
1368            The formula of the ion as a string (like 'C2 H4 O2'); or None if the neutral_formula is not a string.
1369        """
1370        # If neutral_formula is not a string, return None
1371        if not isinstance(neutral_formula, str):
1372            return None
1373
1374        # Check if there are spaces in the formula (these are outputs of the MolecularFormula class and do not need to be processed before being passed to the class)
1375        if re.search(r"\s", neutral_formula):
1376            neutral_formula = MolecularFormula(neutral_formula, ion_charge=0)
1377        else:
1378            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
1379            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
1380            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
1381            neutral_formula = MolecularFormula(
1382                dict(
1383                    zip(
1384                        [x[0] for x in elements],
1385                        [int(x[0]) if x else 1 for x in counts],
1386                    )
1387                ),
1388                ion_charge=0,
1389            )
1390        neutral_formula_dict = neutral_formula.to_dict().copy()
1391
1392        adduct_add_dict = ion_type_dict[ion_type][0]
1393        for key in adduct_add_dict:
1394            if key in neutral_formula_dict.keys():
1395                neutral_formula_dict[key] += adduct_add_dict[key]
1396            else:
1397                neutral_formula_dict[key] = adduct_add_dict[key]
1398
1399        adduct_subtract = ion_type_dict[ion_type][1]
1400        for key in adduct_subtract:
1401            neutral_formula_dict[key] -= adduct_subtract[key]
1402
1403        return MolecularFormula(neutral_formula_dict, ion_charge=0).string
1404
1405    @staticmethod
1406    def get_isotope_type(ion_formula):
1407        """From an ion formula, return the 13C isotope type of the ion.
1408
1409        Notes
1410        -----
1411        This is a static method.
1412        If the ion_formula is not a string, this method will return None.
1413        This is currently only functional for 13C isotopes.
1414
1415        Parameters
1416        ----------
1417        ion_formula : str
1418            The formula of the ion, expected to be a string like 'C2 H4 O2'.
1419
1420        Returns
1421        -------
1422        str
1423            The isotope type of the ion, e.g. '13C1', '13C2', etc; or None if the ion_formula does not contain a 13C isotope.
1424
1425        Raises
1426        ------
1427        ValueError
1428            If the ion_formula is not a string.
1429        """
1430        if not isinstance(ion_formula, str):
1431            return None
1432
1433        if re.search(r"\s", ion_formula):
1434            ion_formula = MolecularFormula(ion_formula, ion_charge=0)
1435        else:
1436            raise ValueError('ion_formula should be a string like "C2 H4 O2"')
1437        ion_formula_dict = ion_formula.to_dict().copy()
1438
1439        try:
1440            iso_class = "13C" + str(ion_formula_dict.pop("13C"))
1441        except KeyError:
1442            iso_class = None
1443
1444        return iso_class
1445    
1446    def report_to_csv(self, molecular_metadata=None):
1447        """Create a report of the mass features and their annotations and save it as a CSV file.
1448
1449        Parameters
1450        ----------
1451        molecular_metadata : dict, optional
1452            The molecular metadata. Default is None.
1453        """
1454        report = self.to_report(molecular_metadata=molecular_metadata)
1455        out_file = self.output_file.with_suffix(".csv")
1456        report.to_csv(out_file, index=False)
1457    
1458    def clean_ms1_report(self, ms1_summary_full):
1459        """Clean the MS1 report.
1460
1461        Parameters
1462        ----------
1463        ms1_summary_full : DataFrame
1464            The full MS1 summary DataFrame.
1465
1466        Returns
1467        -------
1468        DataFrame
1469            The cleaned MS1 summary DataFrame.
1470        """
1471        ms1_summary_full = ms1_summary_full.reset_index()
1472        cols_to_keep = [
1473            "mf_id",
1474            "Molecular Formula",
1475            "Ion Type",
1476            "Calculated m/z",
1477            "m/z Error (ppm)",
1478            "m/z Error Score",
1479            "Is Isotopologue",
1480            "Isotopologue Similarity",
1481            "Confidence Score",
1482        ]
1483        ms1_summary = ms1_summary_full[cols_to_keep].copy()
1484        ms1_summary["ion_formula"] = [
1485            self.get_ion_formula(f, a)
1486            for f, a in zip(ms1_summary["Molecular Formula"], ms1_summary["Ion Type"])
1487        ]
1488        ms1_summary["isotopologue_type"] = [
1489            self.get_isotope_type(f) for f in ms1_summary["ion_formula"].tolist()
1490        ]
1491
1492        # Reorder columns
1493        ms1_summary = ms1_summary[
1494            [
1495                "mf_id",
1496                "ion_formula",
1497                "isotopologue_type",
1498                "Calculated m/z",
1499                "m/z Error (ppm)",
1500                "m/z Error Score",
1501                "Isotopologue Similarity",
1502                "Confidence Score",
1503            ]
1504        ]
1505
1506        # Set the index to mf_id
1507        ms1_summary = ms1_summary.set_index("mf_id")
1508
1509        return ms1_summary
1510    
1511    def summarize_ms2_report(self, ms2_annot_report):
1512        """
1513        Summarize the MS2 report.
1514
1515        Parameters
1516        ----------
1517        ms2_annot_report : DataFrame
1518            The MS2 annotation DataFrame with all annotations, output of mass_features_ms2_annot_to_df.
1519        
1520        Returns
1521        -------
1522        """
1523    
1524    def summarize_metabolomics_report(self, ms2_annot_report):
1525        """Summarize the MS2 hits for a metabolomics report
1526        
1527        Parameters
1528        ----------
1529        ms2_annot : DataFrame
1530            The MS2 annotation DataFrame with all annotations.
1531
1532        Returns
1533        -------
1534        DataFrame
1535            The summarized metabolomics report.
1536        """
1537        columns_to_drop = [
1538            "precursor_mz",
1539            "precursor_mz_error_ppm",
1540            "cas",
1541            "data_id",
1542            "iupac_name",
1543            "traditional_name",
1544            "common_name",
1545            "casno",
1546        ]
1547        ms2_annot = ms2_annot_report.drop(
1548            columns=[col for col in columns_to_drop if col in ms2_annot_report.columns]
1549        )
1550        
1551        # Prepare information about the search results, pulling out the best hit for the single report
1552        # Group by mf_id,ref_mol_id grab row with highest entropy similarity
1553        ms2_annot = ms2_annot.reset_index()
1554        # Add column called "n_spectra_contributing" that is the number of unique values in query_spectrum_id per mf_id,ref_mol_id
1555        ms2_annot["n_spectra_contributing"] = (
1556            ms2_annot.groupby(["mf_id", "ref_mol_id"])["query_spectrum_id"]
1557            .transform("nunique")
1558        )
1559        # Sort by entropy similarity
1560        ms2_annot = ms2_annot.sort_values(
1561            by=["mf_id", "ref_mol_id", "entropy_similarity"], ascending=[True, True, False]
1562        )
1563        best_entropy = ms2_annot.drop_duplicates(
1564            subset=["mf_id", "ref_mol_id"], keep="first"
1565        )
1566
1567        return best_entropy
1568
1569    def clean_ms2_report(self, metabolite_summary):
1570        """Clean the MS2 report.
1571
1572        Parameters
1573        ----------
1574        metabolite_summary : DataFrame
1575            The full metabolomics summary DataFrame.
1576
1577        Returns
1578        -------
1579        DataFrame
1580            The cleaned metabolomics summary DataFrame.
1581        """
1582        metabolite_summary = metabolite_summary.reset_index()
1583        metabolite_summary["ion_formula"] = [
1584            self.get_ion_formula(f, a)
1585            for f, a in zip(metabolite_summary["formula"], metabolite_summary["ref_ion_type"])
1586        ]
1587
1588        col_order = [
1589            "mf_id",
1590            "ion_formula",
1591            "ref_ion_type",
1592            "formula",
1593            "inchikey",
1594            "name",
1595            "inchi",
1596            "chebi",
1597            "smiles",
1598            "kegg",
1599            "cas",
1600            "database_name",
1601            "ref_ms_id",
1602            "entropy_similarity",
1603            "ref_mz_in_query_fract",
1604            "n_spectra_contributing",
1605        ]
1606
1607        # Reorder columns
1608        metabolite_summary = metabolite_summary[
1609            [col for col in col_order if col in metabolite_summary.columns]
1610        ]
1611
1612        # Convert chebi (if present) to int:
1613        if "chebi" in metabolite_summary.columns:
1614            metabolite_summary["chebi"] = metabolite_summary["chebi"].astype(
1615                "Int64", errors="ignore"
1616            )
1617
1618        # Set the index to mf_id
1619        metabolite_summary = metabolite_summary.set_index("mf_id")
1620
1621        return metabolite_summary
1622    
1623    def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
1624        """Combine the mass feature report with the MS1 and MS2 reports.
1625
1626        Parameters
1627        ----------
1628        mf_report : DataFrame
1629            The mass feature report DataFrame.
1630        ms1_annot_report : DataFrame
1631            The MS1 annotation report DataFrame.
1632        ms2_annot_report : DataFrame
1633            The MS2 annotation report DataFrame.
1634        """
1635        # If there is an ms1_annot_report, merge it with the mf_report
1636        if ms1_annot_report is not None and not ms1_annot_report.empty:
1637            # MS1 has been run and has molecular formula information
1638            mf_report = pd.merge(
1639                mf_report,
1640                ms1_annot_report,
1641                how="left",
1642                on=["mf_id", "isotopologue_type"],
1643            )
1644        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:
1649                # pull out the records without ion_formula and merge on mf_id only
1650                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
1652                mf_no_ion_formula = pd.merge(
1653                    mf_no_ion_formula, ms2_annot_report, how="left", on=["mf_id"]
1654                )
1655
1656                # pull out the records with ion_formula and merge on mf_id + ion_formula
1657                mf_with_ion_formula = mf_report[~mf_report["ion_formula"].isna()]
1658                mf_with_ion_formula = pd.merge(
1659                    mf_with_ion_formula,
1660                    ms2_annot_report,
1661                    how="left",
1662                    on=["mf_id", "ion_formula"],
1663                )
1664
1665                # put back together
1666                mf_report = pd.concat([mf_no_ion_formula, mf_with_ion_formula])
1667            else:
1668                # Fall back to merging on mf_id only (robust when ion_formula missing)
1669                mf_report = pd.merge(
1670                    mf_report, ms2_annot_report, how="left", on=["mf_id"]
1671                )
1672
1673        # Rename colums
1674        rename_dict = {
1675            "mf_id": "Mass Feature ID",
1676            "scan_time": "Retention Time (min)",
1677            "mz": "m/z",
1678            "apex_scan": "Apex Scan Number",
1679            "intensity": "Intensity",
1680            "persistence": "Persistence",
1681            "area": "Area",
1682            "half_height_width": "Half Height Width (min)",
1683            "tailing_factor": "Tailing Factor",
1684            "dispersity_index": "Dispersity Index",
1685            "ms2_spectrum": "MS2 Spectrum",
1686            "monoisotopic_mf_id": "Monoisotopic Mass Feature ID",
1687            "isotopologue_type": "Isotopologue Type",
1688            "mass_spectrum_deconvoluted_parent": "Is Largest Ion after Deconvolution",
1689            "associated_mass_features": "Associated Mass Features after Deconvolution",
1690            "ion_formula": "Ion Formula",
1691            "formula": "Molecular Formula",
1692            "ref_ion_type": "Ion Type",
1693            "annot_level": "Lipid Annotation Level",
1694            "lipid_molecular_species_id": "Lipid Molecular Species",
1695            "lipid_summed_name": "Lipid Species",
1696            "lipid_subclass": "Lipid Subclass",
1697            "lipid_class": "Lipid Class",
1698            "lipid_category": "Lipid Category",
1699            "entropy_similarity": "Entropy Similarity",
1700            "ref_mz_in_query_fract": "Library mzs in Query (fraction)",
1701            "n_spectra_contributing": "Spectra with Annotation (n)",
1702        }
1703        mf_report = mf_report.rename(columns=rename_dict)
1704        mf_report["Sample Name"] = self.mass_spectra.sample_name
1705        mf_report["Polarity"] = self.mass_spectra.polarity
1706        mf_report = mf_report[
1707            ["Mass Feature ID", "Sample Name", "Polarity"]
1708            + [
1709                col
1710                for col in mf_report.columns
1711                if col not in ["Mass Feature ID", "Sample Name", "Polarity"]
1712            ]
1713        ]
1714
1715        # Reorder rows by "Mass Feature ID", then "Entropy Similarity" (descending), then "Confidence Score" (descending)
1716        if "Entropy Similarity" in mf_report.columns and "Confidence Score" in mf_report.columns:
1717            mf_report = mf_report.sort_values(
1718                by=["Mass Feature ID", "Entropy Similarity", "Confidence Score"],
1719                ascending=[True, False, False],
1720            )
1721        elif "Entropy Similarity" in mf_report.columns:
1722             mf_report = mf_report.sort_values(
1723                by=["Mass Feature ID", "Entropy Similarity"],
1724                ascending=[True, False],
1725            )
1726        elif "Confidence Score" in mf_report.columns:
1727             mf_report = mf_report.sort_values(
1728                by=["Mass Feature ID", "Confidence Score"],
1729                ascending=[True, False],
1730            )
1731        # If neither "Entropy Similarity" nor "Confidence Score" are in the columns, just sort by "Mass Feature ID"
1732        else:
1733            mf_report = mf_report.sort_values("Mass Feature ID")
1734
1735        # Reset index
1736        mf_report = mf_report.reset_index(drop=True)
1737
1738        return mf_report
1739    
1740    def to_report(self, molecular_metadata=None, suppress_warnings=False):
1741        """Create a report of the mass features and their annotations.
1742
1743        Parameters
1744        ----------
1745        molecular_metadata : dict, optional
1746            The molecular metadata. Default is None.
1747        suppress_warnings : bool, optional
1748            If True, suppresses warnings from mass_features_ms2_annot_to_df.
1749            Default is False.
1750
1751        Returns
1752        -------
1753        DataFrame
1754            The report as a Pandas DataFrame.
1755        """
1756        # Get mass feature dataframe
1757        mf_report = self.mass_spectra.mass_features_to_df()
1758        mf_report = mf_report.reset_index(drop=False)
1759
1760        # Get and clean ms1 annotation dataframe
1761        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df(suppress_warnings=suppress_warnings)
1762        if ms1_annot_report is not None:
1763            ms1_annot_report = ms1_annot_report.copy()
1764            ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
1765            ms1_annot_report = ms1_annot_report.reset_index(drop=False)
1766        else:
1767            ms1_annot_report = None
1768
1769        # Get, summarize, and clean ms2 annotation dataframe
1770        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
1771            molecular_metadata=molecular_metadata,
1772            suppress_warnings=suppress_warnings
1773        )
1774        if ms2_annot_report is not None and molecular_metadata is not None:
1775            ms2_annot_report = self.summarize_metabolomics_report(ms2_annot_report)
1776            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
1777            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
1778            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
1779        else:
1780            ms2_annot_report = None
1781
1782        report = self.combine_reports(
1783            mf_report=mf_report,
1784            ms1_annot_report=ms1_annot_report,
1785            ms2_annot_report=ms2_annot_report
1786        )
1787
1788        return report
1789class LipidomicsExport(LCMSMetabolomicsExport):
1790    """A class to export lipidomics data.
1791
1792    This class provides methods to export lipidomics data to various formats and summarize the lipid report.
1793
1794    Parameters
1795    ----------
1796    out_file_path : str | Path
1797        The output file path, do not include the file extension.
1798    mass_spectra : object
1799        The high resolution mass spectra object.
1800    """
1801
1802    def __init__(self, out_file_path, mass_spectra):
1803        super().__init__(out_file_path, mass_spectra)
1804
1805    def summarize_lipid_report(self, ms2_annot):
1806        """Summarize the lipid report.
1807
1808        Parameters
1809        ----------
1810        ms2_annot : DataFrame
1811            The MS2 annotation DataFrame with all annotations.
1812
1813        Returns
1814        -------
1815        DataFrame
1816            The summarized lipid report.
1817        """
1818        # Drop unnecessary columns for easier viewing
1819        columns_to_drop = [
1820            "precursor_mz",
1821            "precursor_mz_error_ppm",
1822            "ref_mol_id",
1823            "ref_precursor_mz",
1824            "cas",
1825            "inchikey",
1826            "inchi",
1827            "chebi",
1828            "smiles",
1829            "kegg",
1830            "data_id",
1831            "iupac_name",
1832            "traditional_name",
1833            "common_name",
1834            "casno",
1835        ]
1836        ms2_annot = ms2_annot.drop(
1837            columns=[col for col in columns_to_drop if col in ms2_annot.columns]
1838        )
1839
1840        # If ion_types_excluded is not empty, remove those ion types
1841        ion_types_excluded = self.mass_spectra.parameters.mass_spectrum[
1842            "ms2"
1843        ].molecular_search.ion_types_excluded
1844        if len(ion_types_excluded) > 0:
1845            ms2_annot = ms2_annot[~ms2_annot["ref_ion_type"].isin(ion_types_excluded)]
1846
1847        # If mf_id is not present, check that the index name is mf_id and reset the index
1848        if "mf_id" not in ms2_annot.columns:
1849            if ms2_annot.index.name == "mf_id":
1850                ms2_annot = ms2_annot.reset_index()
1851            else:
1852                raise ValueError("mf_id is not present in the dataframe")
1853
1854        # Attempt to get consensus annotations to the MLF level
1855        mlf_results_all = []
1856        for mf_id in ms2_annot["mf_id"].unique():
1857            mlf_results_perid = []
1858            ms2_annot_mf = ms2_annot[ms2_annot["mf_id"] == mf_id].copy()
1859            ms2_annot_mf["n_spectra_contributing"] = ms2_annot_mf.query_spectrum_id.nunique()
1860
1861            for query_scan in ms2_annot["query_spectrum_id"].unique():
1862                ms2_annot_sub = ms2_annot_mf[
1863                    ms2_annot_mf["query_spectrum_id"] == query_scan
1864                ].copy()
1865
1866                if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1867                    # If there is only one lipid_summed_name, let's try to get consensus molecular species annotation
1868                    if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1869                        ms2_annot_sub["entropy_max"] = (
1870                            ms2_annot_sub["entropy_similarity"]
1871                            == ms2_annot_sub["entropy_similarity"].max()
1872                        )
1873                        ms2_annot_sub["ref_match_fract_max"] = (
1874                            ms2_annot_sub["ref_mz_in_query_fract"]
1875                            == ms2_annot_sub["ref_mz_in_query_fract"].max()
1876                        )
1877                        ms2_annot_sub["frag_max"] = ms2_annot_sub[
1878                            "query_frag_types"
1879                        ].apply(lambda x: True if "MLF" in x else False)
1880
1881                        # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1882                        ms2_annot_sub["consensus"] = ms2_annot_sub[
1883                            ["entropy_max", "ref_match_fract_max", "frag_max"]
1884                        ].all(axis=1)
1885
1886                        # If there is a consensus, take the row with the highest entropy_similarity
1887                        if ms2_annot_sub["consensus"].any():
1888                            ms2_annot_sub = ms2_annot_sub[
1889                                ms2_annot_sub["entropy_similarity"]
1890                                == ms2_annot_sub["entropy_similarity"].max()
1891                            ].head(1)
1892                            mlf_results_perid.append(ms2_annot_sub)
1893            if len(mlf_results_perid) == 0:
1894                mlf_results_perid = pd.DataFrame()
1895            else:
1896                mlf_results_perid = pd.concat(mlf_results_perid)
1897                if mlf_results_perid["name"].nunique() == 1:
1898                    mlf_results_perid = mlf_results_perid[
1899                        mlf_results_perid["entropy_similarity"]
1900                        == mlf_results_perid["entropy_similarity"].max()
1901                    ].head(1)
1902                else:
1903                    mlf_results_perid = pd.DataFrame()
1904                mlf_results_all.append(mlf_results_perid)
1905
1906        # These are the consensus annotations to the MLF level
1907        if len(mlf_results_all) > 0:
1908            mlf_results_all = pd.concat(mlf_results_all)
1909            mlf_results_all["annot_level"] = mlf_results_all["structure_level"]
1910        else:
1911            # Make an empty dataframe
1912            mlf_results_all = ms2_annot.head(0)
1913
1914        # For remaining mf_ids, try to get a consensus annotation to the species level
1915        species_results_all = []
1916        # Remove mf_ids that have consensus annotations to the MLF level
1917        ms2_annot_spec = ms2_annot[
1918            ~ms2_annot["mf_id"].isin(mlf_results_all["mf_id"].unique())
1919        ]
1920        for mf_id in ms2_annot_spec["mf_id"].unique():
1921            # Do all the hits have the same lipid_summed_name?
1922            ms2_annot_sub = ms2_annot_spec[ms2_annot_spec["mf_id"] == mf_id].copy()
1923            ms2_annot_sub["n_spectra_contributing"] = len(ms2_annot_sub)
1924
1925            if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1926                # Grab the highest entropy_similarity result
1927                ms2_annot_sub = ms2_annot_sub[
1928                    ms2_annot_sub["entropy_similarity"]
1929                    == ms2_annot_sub["entropy_similarity"].max()
1930                ].head(1)
1931                species_results_all.append(ms2_annot_sub)
1932
1933        # These are the consensus annotations to the species level
1934        if len(species_results_all) > 0:
1935            species_results_all = pd.concat(species_results_all)
1936            species_results_all["annot_level"] = "species"
1937        else:
1938            # Make an empty dataframe
1939            species_results_all = ms2_annot.head(0)
1940
1941        # Deal with the remaining mf_ids that do not have consensus annotations to the species level or MLF level
1942        # Remove mf_ids that have consensus annotations to the species level
1943        ms2_annot_remaining = ms2_annot_spec[
1944            ~ms2_annot_spec["mf_id"].isin(species_results_all["mf_id"].unique())
1945        ]
1946        no_consensus = []
1947        for mf_id in ms2_annot_remaining["mf_id"].unique():
1948            id_sub = []
1949            id_no_con = []
1950            ms2_annot_sub_mf = ms2_annot_remaining[
1951                ms2_annot_remaining["mf_id"] == mf_id
1952            ].copy()
1953            for query_scan in ms2_annot_sub_mf["query_spectrum_id"].unique():
1954                ms2_annot_sub = ms2_annot_sub_mf[
1955                    ms2_annot_sub_mf["query_spectrum_id"] == query_scan
1956                ].copy()
1957
1958                # New columns for ranking [HIGHER RANK = BETTER]
1959                ms2_annot_sub["entropy_max"] = (
1960                    ms2_annot_sub["entropy_similarity"]
1961                    == ms2_annot_sub["entropy_similarity"].max()
1962                )
1963                ms2_annot_sub["ref_match_fract_max"] = (
1964                    ms2_annot_sub["ref_mz_in_query_fract"]
1965                    == ms2_annot_sub["ref_mz_in_query_fract"].max()
1966                )
1967                ms2_annot_sub["frag_max"] = ms2_annot_sub["query_frag_types"].apply(
1968                    lambda x: True if "MLF" in x else False
1969                )
1970
1971                # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1972                ms2_annot_sub["consensus"] = ms2_annot_sub[
1973                    ["entropy_max", "ref_match_fract_max", "frag_max"]
1974                ].all(axis=1)
1975                ms2_annot_sub_con = ms2_annot_sub[ms2_annot_sub["consensus"]]
1976                id_sub.append(ms2_annot_sub_con)
1977                id_no_con.append(ms2_annot_sub)
1978            id_sub = pd.concat(id_sub)
1979            id_no_con = pd.concat(id_no_con)
1980
1981            # Scenario 1: Multiple scans are being resolved to different MLFs [could be coelutions and should both be kept and annotated to MS level]
1982            if (
1983                id_sub["query_frag_types"]
1984                .apply(lambda x: True if "MLF" in x else False)
1985                .all()
1986                and len(id_sub) > 0
1987            ):
1988                idx = id_sub.groupby("name")["entropy_similarity"].idxmax()
1989                id_sub = id_sub.loc[idx]
1990                # Reorder so highest entropy_similarity is first
1991                id_sub = id_sub.sort_values("entropy_similarity", ascending=False)
1992                id_sub["annot_level"] = id_sub["structure_level"]
1993                no_consensus.append(id_sub)
1994
1995            # Scenario 2: Multiple scans are being resolved to different species, keep both and annotate to appropriate level
1996            elif len(id_sub) == 0:
1997                for lipid_summed_name in id_no_con["lipid_summed_name"].unique():
1998                    summed_sub = id_no_con[
1999                        id_no_con["lipid_summed_name"] == lipid_summed_name
2000                    ]
2001                    # Any consensus to MLF?
2002                    if summed_sub["consensus"].any():
2003                        summed_sub = summed_sub[summed_sub["consensus"]]
2004                        summed_sub["annot_level"] = summed_sub["structure_level"]
2005                        no_consensus.append(summed_sub)
2006                    else:
2007                        # Grab the highest entropy_similarity, if there are multiple, grab the first one
2008                        summed_sub = summed_sub[
2009                            summed_sub["entropy_similarity"]
2010                            == summed_sub["entropy_similarity"].max()
2011                        ].head(1)
2012                        # get first row
2013                        summed_sub["annot_level"] = "species"
2014                        summed_sub["name"] = ""
2015                        no_consensus.append(summed_sub)
2016            else:
2017                raise ValueError("Unexpected scenario for summarizing mf_id: ", mf_id)
2018
2019        if len(no_consensus) > 0:
2020            no_consensus = pd.concat(no_consensus)
2021        else:
2022            no_consensus = ms2_annot.head(0)
2023
2024        # Combine all the consensus annotations and reformat the dataframe for output
2025        species_results_all = species_results_all.drop(columns=["name"])
2026        species_results_all["lipid_molecular_species_id"] = ""
2027        mlf_results_all["lipid_molecular_species_id"] = mlf_results_all["name"]
2028        no_consensus["lipid_molecular_species_id"] = no_consensus["name"]
2029        consensus_annotations = pd.concat(
2030            [mlf_results_all, species_results_all, no_consensus]
2031        )
2032        consensus_annotations = consensus_annotations.sort_values(
2033            "mf_id", ascending=True
2034        )
2035        cols_to_keep = [
2036            "mf_id",
2037            "ref_ion_type",
2038            "entropy_similarity",
2039            "ref_mz_in_query_fract",
2040            "lipid_molecular_species_id",
2041            "lipid_summed_name",
2042            "lipid_subclass",
2043            "lipid_class",
2044            "lipid_category",
2045            "formula",
2046            "annot_level",
2047            "n_spectra_contributing",
2048        ]
2049        consensus_annotations = consensus_annotations[cols_to_keep]
2050        consensus_annotations = consensus_annotations.set_index("mf_id")
2051
2052        return consensus_annotations
2053
2054    def clean_ms2_report(self, lipid_summary):
2055        """Clean the MS2 report.
2056
2057        Parameters
2058        ----------
2059        lipid_summary : DataFrame
2060            The full lipid summary DataFrame.
2061
2062        Returns
2063        -------
2064        DataFrame
2065            The cleaned lipid summary DataFrame.
2066        """
2067        lipid_summary = lipid_summary.reset_index()
2068        lipid_summary["ion_formula"] = [
2069            self.get_ion_formula(f, a)
2070            for f, a in zip(lipid_summary["formula"], lipid_summary["ref_ion_type"])
2071        ]
2072
2073        # Reorder columns
2074        lipid_summary = lipid_summary[
2075            [
2076                "mf_id",
2077                "ion_formula",
2078                "ref_ion_type",
2079                "formula",
2080                "annot_level",
2081                "lipid_molecular_species_id",
2082                "lipid_summed_name",
2083                "lipid_subclass",
2084                "lipid_class",
2085                "lipid_category",
2086                "entropy_similarity",
2087                "ref_mz_in_query_fract",
2088                "n_spectra_contributing",
2089            ]
2090        ]
2091
2092        # Set the index to mf_id
2093        lipid_summary = lipid_summary.set_index("mf_id")
2094
2095        return lipid_summary
2096
2097    def to_report(self, molecular_metadata=None):
2098        """Create a report of the mass features and their annotations.
2099
2100        Parameters
2101        ----------
2102        molecular_metadata : dict, optional
2103            The molecular metadata. Default is None.
2104
2105        Returns
2106        -------
2107        DataFrame
2108            The report of the mass features and their annotations.
2109
2110        Notes
2111        -----
2112        The report will contain the mass features and their annotations from MS1 and MS2 (if available).
2113        """
2114        # Get mass feature dataframe
2115        mf_report = self.mass_spectra.mass_features_to_df()
2116        mf_report = mf_report.reset_index(drop=False)
2117
2118        # Get and clean ms1 annotation dataframe
2119        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df().copy()
2120        ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
2121        ms1_annot_report = ms1_annot_report.reset_index(drop=False)
2122
2123        # Get, summarize, and clean ms2 annotation dataframe
2124        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
2125            molecular_metadata=molecular_metadata
2126        )
2127        if ms2_annot_report is not None and molecular_metadata is not None:
2128            ms2_annot_report = self.summarize_lipid_report(ms2_annot_report)
2129            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
2130            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
2131            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
2132        report = self.combine_reports(
2133            mf_report=mf_report,
2134            ms1_annot_report=ms1_annot_report,
2135            ms2_annot_report=ms2_annot_report
2136        )
2137        return report
2138
2139        
2140class LCMSCollectionExport():
2141    """A class to export an LCMS collection to HDF5 format.
2142
2143    This class provides methods to export collection-level data from multi-sample LC-MS
2144    experiments to HDF5 files. It handles the export of metadata, retention time alignments,
2145    cluster assignments, and induced mass features (gap-filled features) across the collection.
2146
2147    The exporter is designed to work with LCMSCollection objects and complements the individual
2148    LCMSExport class by focusing on collection-wide data rather than individual sample data.
2149
2150    Parameters
2151    ----------
2152    out_file_path : str | Path
2153        The output file path, do not include the file extension. The .hdf5 extension
2154        will be added automatically.
2155    mass_spectra_collection : LCMSCollection
2156        The LCMS collection object containing multiple LCMS samples with processed mass features,
2157        alignments, and clustering information.
2158
2159    Attributes
2160    ----------
2161    out_file_path : Path
2162        The output file path as a Path object.
2163    mass_spectra_collection : LCMSCollection
2164        The LCMS collection object to be exported.
2165
2166    Methods
2167    -------
2168    export_to_hdf5(overwrite=False)
2169        Export the LCMS collection to an HDF5 file with collection-level data.
2170
2171    Notes
2172    -----
2173    This class exports collection-level data including:
2174    - Sample manifest (metadata about all samples in the collection)
2175    - Retention time alignment data (if RT alignment has been performed)
2176    - Cluster assignments (consensus mass feature groupings across samples)
2177    - Induced mass features (gap-filled features saved to individual LCMS object HDF5 files)
2178
2179    Individual sample data (mass spectra, mass features, EICs, etc.) should be exported
2180    separately using the LCMSExport class for each LCMS object in the collection.
2181
2182    Examples
2183    --------
2184    Export a collection after clustering and gap-filling:
2185
2186    >>> from corems.mass_spectra.output.export import LCMSCollectionExporter
2187    >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
2188    >>> exporter.export_to_hdf5(overwrite=True)
2189
2190    The resulting HDF5 file will contain collection-level metadata and can be used
2191    to reconstruct the collection state for further analysis.
2192
2193    See Also
2194    --------
2195    LCMSExport : Export individual LCMS objects to HDF5
2196    LCMSCollection : The collection object being exported
2197    """
2198    def __init__(self, out_file_path, mass_spectra_collection):
2199        self.out_file_path = Path(out_file_path)
2200        self.mass_spectra_collection = mass_spectra_collection
2201
2202    def export_to_hdf5(
2203            self, 
2204            overwrite = False, 
2205            save_parameters=True, 
2206            parameter_format="toml",
2207            update_lcms_objects=True):
2208        """Export the LCMS collection to an HDF5 file.
2209        
2210        This method saves the collection-level data to an HDF5 file, including:
2211        - Basic metadata (date, folder location, gap-filling status)
2212        - Sample manifest
2213        - Retention time alignments (if available)
2214        - Cluster assignments (if available)
2215        - Induced mass features for each LCMS object (if gap-filling was performed)
2216        
2217        Individual LCMS objects in the collection are not exported by this method.
2218        Use LCMSExport for exporting individual LCMS objects.
2219        
2220        Parameters
2221        ----------
2222        overwrite : bool, optional
2223            If True, overwrites the output file if it already exists and replaces
2224            existing groups within the HDF5 file. If False, appends new data to
2225            existing file without overwriting existing groups. Default is False.
2226        save_parameters : bool, optional
2227            If True, saves the collection-level parameters to a separate file in the specified format.
2228            Default is True.
2229        parameter_format : str, optional
2230            The format for saving parameters, either "json" or "toml". Default is "toml".
2231        update_lcms_objects : bool, optional
2232            If True, updates the individual LCMS object HDF5 files with new raw file locations and any additional 
2233            information produced during the processing of the collection (e.g. cluster mass feature associations). Default is True.
2234            
2235        Notes
2236        -----
2237        The HDF5 file structure includes:
2238        - Attributes: date_utc, lcms_objects_folder, missing_mass_features_searched, manifest
2239        - Groups: rt_alignments, cluster_assignments (if available)
2240        
2241        Induced mass features are saved to the individual LCMS object HDF5 files
2242        within the .corems folder structure, not in the collection-level HDF5 file.
2243        
2244        Examples
2245        --------
2246        >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
2247        >>> exporter.export_to_hdf5(overwrite=True)
2248        """
2249        if overwrite:
2250            if self.out_file_path.with_suffix(".hdf5").exists():
2251                self.out_file_path.with_suffix(".hdf5").unlink()
2252
2253        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2254            # Add basic attributes to the HDF5 file, always overwrite these
2255            timenow = str(
2256                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
2257                )
2258            hdf_handle.attrs["date_utc"] = timenow
2259            hdf_handle.attrs["lcms_objects_folder"] = str(self.mass_spectra_collection.collection_parser.folder_location)
2260            hdf_handle.attrs["missing_mass_features_searched"] = self.mass_spectra_collection.missing_mass_features_searched
2261            hdf_handle.attrs["rt_aligned"] = self.mass_spectra_collection.rt_aligned
2262            hdf_handle.attrs["rt_alignment_attempted"] = self.mass_spectra_collection.rt_alignment_attempted
2263
2264            # Add the manifest to the HDF5 file, always overwrite this
2265            hdf_handle.attrs["manifest"] = self._convert_manifest_to_json()
2266
2267            # Save retention time alignments if they exist, only overwrite if specified
2268            self._save_rt_alignments_to_hdf5(hdf_handle, overwrite)
2269
2270            # Save cluster assignments if they exist, only overwrite if specified
2271            self._save_cluster_assignments_to_hdf5(hdf_handle, overwrite)
2272
2273        # Save new raw file locations to each LCMS object's HDF5 file if needed
2274        if hasattr(self.mass_spectra_collection, 'raw_files_relocated') and self.mass_spectra_collection.raw_files_relocated:
2275            self._update_raw_file_locations_in_hdf5()
2276
2277        # Save induced mass features to the collection with associations to each individual, only if lcms_collection.missing_mass_features_searched is True
2278        if self.mass_spectra_collection.missing_mass_features_searched:
2279            self._save_induced_mass_features_to_hdf5(overwrite)
2280            # Save EICs for induced mass features at collection level
2281            self._save_induced_eics_to_hdf5(overwrite)
2282        
2283        # Build cluster mass feature map to know which features to update
2284        # This uses the same logic as process_consensus_features to determine loaded features
2285        cluster_mf_map = self._build_cluster_mf_map()
2286        
2287        # Save updated mass features for each LCMS object
2288        # This implements selective update: only loaded features are updated, non-cluster features are preserved
2289        if update_lcms_objects:
2290            self._save_lcms_objects_to_hdf5(cluster_mf_map, overwrite)
2291
2292        # Save collection-level parameters as separate file
2293        if save_parameters:
2294            # Check if parameter_format is valid
2295            if parameter_format not in ["json", "toml"]:
2296                raise ValueError("parameter_format must be 'json' or 'toml'")
2297
2298            if parameter_format == "json":
2299                dump_lcms_collection_settings_json(
2300                    filename=self.out_file_path.with_suffix(".json"),
2301                    lcms_collection=self.mass_spectra_collection,
2302                )
2303            elif parameter_format == "toml":
2304                dump_lcms_collection_settings_toml(
2305                    filename=self.out_file_path.with_suffix(".toml"),
2306                    lcms_collection=self.mass_spectra_collection,
2307                )
2308
2309    def _save_rt_alignments_to_hdf5(self, hdf_handle, overwrite):
2310        """Save retention time alignments to HDF5 file."""
2311        # If no rt_alignments, return early
2312        if not self.mass_spectra_collection.rt_aligned:
2313            return
2314        
2315        # If rt_alignments exist, save them
2316        if self.mass_spectra_collection.rt_aligned:
2317            group_name = "rt_alignments"
2318            # grab dictionary of rt_alignments
2319            rt_alignments = self.mass_spectra_collection.rt_alignments
2320
2321        if rt_alignments:
2322            # Check if group exists and handle overwrite logic
2323            if group_name in hdf_handle:
2324                if not overwrite:
2325                    return
2326                del hdf_handle[group_name]
2327            
2328            grp = hdf_handle.create_group(group_name)
2329            
2330            # Save each alignment as a dataset
2331            for sample_idx, alignment_data in rt_alignments.items():
2332                grp.create_dataset(str(sample_idx), data=alignment_data)
2333    
2334    def _convert_manifest_to_json(self):
2335        """Clean the manifest for export to HDF5."""
2336        manifest = self.mass_spectra_collection.collection_parser.manifest
2337        
2338        # Process the manifest to convert numpy.bool_ or bool values for the 'use_rt_alignment' key
2339        def convert_bool_values(data):
2340            if isinstance(data, dict):
2341                # Process each key-value pair recursively
2342                return {k: (int(v) if k == 'use_rt_alignment' and isinstance(v, (bool, np.bool_)) else convert_bool_values(v)) for k, v in data.items()}
2343            elif isinstance(data, list):
2344                # Recursively process lists
2345                return [convert_bool_values(item) for item in data]
2346            else:
2347                # Return non-dict/list types unchanged
2348                return data
2349        
2350        # Clean the whole manifest
2351        cleaned_manifest = convert_bool_values(manifest)
2352
2353        # Serialize the cleaned manifest into JSON format
2354        json_manifest = json.dumps(cleaned_manifest)
2355        return json_manifest
2356    
2357    def _save_cluster_assignments_to_hdf5(self, hdf_handle, overwrite):
2358        """Save cluster assignments to HDF5 file."""
2359        # Check if column "cluster" is present in self.mass_features_dataframe
2360        if "cluster" in self.mass_spectra_collection.mass_features_dataframe.columns:
2361            group_name = "cluster_assignments"
2362            cluster_assignments = self.mass_spectra_collection.mass_features_dataframe[["cluster"]].copy()
2363
2364            # Check if group exists and handle overwrite logic
2365            if group_name in hdf_handle:
2366                if not overwrite:
2367                    return
2368                del hdf_handle[group_name]
2369            
2370            grp = hdf_handle.create_group(group_name)
2371
2372            # Save the index, converting strings to bytes
2373            grp.create_dataset("index", data=cluster_assignments.index.astype(str).values.astype('S'))
2374            
2375            # Save the "cluster" column
2376            grp.create_dataset("cluster", data=cluster_assignments["cluster"].values)
2377    
2378    def _build_cluster_mf_map(self):
2379        """Build a mapping of which mass features should be saved for each sample.
2380        
2381        This uses the same logic as process_consensus_features to determine which
2382        mass features were loaded and should be updated in HDF5 files.
2383        
2384        Returns
2385        -------
2386        dict
2387            Dictionary mapping sample_id to list of tuples (mf_id, cluster_id).
2388            Only includes samples that have loaded representative features.
2389            Returns empty dict if no clusters exist.
2390        
2391        Notes
2392        -----
2393        This follows the DRY principle by using the same get_sample_mf_map_for_representatives
2394        method used by process_consensus_features and ReadSavedLCMSCollection.
2395        """
2396        # Check if clusters exist
2397        if "cluster" not in self.mass_spectra_collection.mass_features_dataframe.columns:
2398            return {}
2399        
2400        # Check if cluster_summary_dataframe exists (needed by get_sample_mf_map_for_representatives)
2401        if not hasattr(self.mass_spectra_collection, 'cluster_summary_dataframe') or \
2402           self.mass_spectra_collection.cluster_summary_dataframe is None:
2403            return {}
2404        
2405        # Use the same DRY helper method that process_consensus_features uses
2406        # This ensures consistency across the codebase
2407        cluster_mf_map = self.mass_spectra_collection.get_sample_mf_map_for_representatives(
2408            include_cluster_id=True
2409        )
2410        
2411        return cluster_mf_map
2412    
2413    def _update_raw_file_locations_in_hdf5(self):
2414        """Update raw file locations in each LCMS object's HDF5 file.
2415        
2416        This method updates the 'original_file_location' attribute in each LCMS object's
2417        HDF5 file to reflect the new raw file location after files have been relocated.
2418        """
2419        for lcms_obj in self.mass_spectra_collection:
2420            # Get the HDF5 file path for this LCMS object
2421            hdf5_path = lcms_obj.file_location.with_suffix('.hdf5')
2422            
2423            if hdf5_path.exists():
2424                with h5py.File(hdf5_path, 'a') as hdf_handle:
2425                    # Update the original_file_location attribute
2426                    if 'original_file_location' in hdf_handle.attrs:
2427                        hdf_handle.attrs['original_file_location'] = str(lcms_obj.raw_file_location)
2428                    # If the attribute does not exist, create it
2429                    else:
2430                        hdf_handle.attrs.create('original_file_location', str(lcms_obj.raw_file_location))
2431    
2432    def _save_induced_mass_features_to_hdf5(self, overwrite):
2433        """Save induced mass features to the collection HDF5 file.
2434        
2435        Induced mass features are gap-filled features that only exist at the collection level.
2436        They are saved with full detail (all attributes and datasets) in the collection HDF5 file
2437        and distributed to individual LCMS objects when the collection is loaded.
2438        
2439        The induced mass features are stored in the collection's induced_mass_features_dataframe
2440        and are regenerated as LCMSMassFeature objects for saving.
2441        
2442        Parameters
2443        ----------
2444        overwrite : bool
2445            If True, overwrites existing induced mass features group. If False, skips if group exists.
2446        """
2447        # Check if we have any induced mass features to save
2448        if (self.mass_spectra_collection.induced_mass_features_dataframe is None or 
2449            self.mass_spectra_collection.induced_mass_features_dataframe.empty):
2450            return
2451        
2452        # Open the collection HDF5 file to save induced mass features
2453        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2454            group_name = "induced_mass_features"
2455            
2456            # Check if group exists and handle overwrite logic
2457            if group_name in hdf_handle:
2458                if not overwrite:
2459                    return
2460                del hdf_handle[group_name]
2461            
2462            # Create top-level group for induced mass features
2463            imf_group = hdf_handle.create_group(group_name)
2464            
2465            # Get the induced mass features dataframe
2466            induced_df = self.mass_spectra_collection.induced_mass_features_dataframe
2467            
2468            # Get unique sample IDs from the dataframe
2469            sample_ids = induced_df['sample_id'].unique()
2470            
2471            # Iterate through each sample and save its induced mass features
2472            for sample_id in sample_ids:
2473                # Filter dataframe to this sample
2474                sample_df = induced_df[induced_df['sample_id'] == sample_id].copy()
2475                
2476                if sample_df.empty:
2477                    continue
2478                
2479                # Regenerate mass features from the dataframe
2480                regenerated_features = self._regenerate_mass_features_from_sample_df(
2481                    sample_df, sample_id
2482                )
2483                
2484                if not regenerated_features:
2485                    continue
2486                
2487                # Create a subgroup for this sample's induced mass features
2488                sample_group = imf_group.create_group(str(sample_id))
2489                
2490                # Use the static helper method from LCMSExport to save the mass features
2491                LCMSExport._save_mass_features_dict_to_hdf5(
2492                    regenerated_features, 
2493                    sample_group, 
2494                    overwrite=overwrite
2495                )
2496    
2497    def _save_induced_eics_to_hdf5(self, overwrite):
2498        """Save EICs for induced mass features to the collection HDF5 file.
2499        
2500        Induced mass features are gap-filled features created during process_consensus_features.
2501        Their associated EICs need to be saved at the collection level so they can be reloaded.
2502        
2503        The induced mass features are identified from the collection's induced_mass_features_dataframe,
2504        and their EICs are retrieved from the individual LCMS objects.
2505        
2506        Parameters
2507        ----------
2508        overwrite : bool
2509            If True, overwrites existing induced EICs group. If False, skips if group exists.
2510        """
2511        # Check if we have any induced mass features to save
2512        if (self.mass_spectra_collection.induced_mass_features_dataframe is None or 
2513            self.mass_spectra_collection.induced_mass_features_dataframe.empty):
2514            return
2515        
2516        # Open the collection HDF5 file to save induced EICs
2517        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2518            group_name = "induced_eics"
2519            
2520            # Check if group exists and handle overwrite logic
2521            if group_name in hdf_handle:
2522                if not overwrite:
2523                    return
2524                del hdf_handle[group_name]
2525            
2526            # Create top-level group for induced EICs
2527            induced_eics_group = hdf_handle.create_group(group_name)
2528            
2529            # Get the induced mass features dataframe
2530            induced_df = self.mass_spectra_collection.induced_mass_features_dataframe
2531            
2532            # Get unique sample IDs from the dataframe
2533            sample_ids = induced_df['sample_id'].unique()
2534            
2535            # Iterate through each sample and save EICs for its induced mass features
2536            for sample_id in sample_ids:
2537                lcms_obj = self.mass_spectra_collection[sample_id]
2538                
2539                # Filter dataframe to this sample
2540                sample_df = induced_df[induced_df['sample_id'] == sample_id].copy()
2541                
2542                if sample_df.empty:
2543                    continue
2544                
2545                # Collect EICs for induced mass features using _eic_mz from dataframe
2546                induced_eics = {}
2547                for _, row in sample_df.iterrows():
2548                    # Get the EIC m/z from the dataframe
2549                    eic_mz = row.get('_eic_mz')
2550                    
2551                    if eic_mz is not None and pd.notna(eic_mz):
2552                        # Try to get the EIC from the LCMS object
2553                        if hasattr(lcms_obj, 'eics') and lcms_obj.eics and eic_mz in lcms_obj.eics:
2554                            induced_eics[eic_mz] = lcms_obj.eics[eic_mz]
2555                
2556                if not induced_eics:
2557                    continue
2558                
2559                # Create a subgroup for this sample's induced EICs
2560                sample_group = induced_eics_group.create_group(str(sample_id))
2561                
2562                # Use the static helper method from LCMSExport to save the EICs
2563                LCMSExport._save_eics_dict_to_hdf5(induced_eics, sample_group, overwrite)
2564    
2565    def _regenerate_mass_features_from_sample_df(self, sample_df, sample_id):
2566        """Regenerate induced mass features from a sample-specific dataframe.
2567        
2568        This method creates LCMSMassFeature objects from rows in the induced_mass_features_dataframe
2569        for a specific sample. The regenerated features are used for saving to HDF5.
2570        
2571        Parameters
2572        ----------
2573        sample_df : pd.DataFrame
2574            DataFrame containing induced mass features for a specific sample.
2575        sample_id : int
2576            The sample ID (index in the collection).
2577            
2578        Returns
2579        -------
2580        dict
2581            Dictionary of regenerated LCMSMassFeature objects keyed by feature ID.
2582        """
2583        from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
2584        
2585        if sample_df.empty:
2586            return {}
2587        
2588        # Get the corresponding LCMS object for proper parent reference
2589        lcms_obj = self.mass_spectra_collection[sample_id]
2590        
2591        # Regenerate mass features from the dataframe
2592        regenerated_features = {}
2593        
2594        for _, row in sample_df.iterrows():
2595            # Extract the original ID from mf_id (format: c{cluster}_{index}_i)
2596            # This is the ID used in lcms_obj.induced_mass_features dict
2597            original_id = row['mf_id']
2598            
2599            # Create a new LCMSMassFeature with proper parent reference
2600            # Note: dataframe uses 'scan_time' but __init__ parameter is 'retention_time'
2601            mass_feature = LCMSMassFeature(
2602                lcms_parent=lcms_obj,
2603                mz=row['mz'],
2604                retention_time=row['scan_time'],  # Column is 'scan_time' in dataframe
2605                intensity=row['intensity'],
2606                apex_scan=int(row['apex_scan']),
2607                persistence=row.get('persistence', None) if 'persistence' in row else None,
2608                id=original_id  # Use the original string ID from gap-filling
2609            )
2610            
2611            # Set additional attributes dynamically from dataframe columns
2612            # Skip columns already handled in __init__ or structural metadata
2613            skip_cols = {
2614                'sample_id', 'mf_id', 'mz', 'scan_time', 'scan_time_aligned',
2615                'intensity', 'apex_scan', 'persistence'}
2616            
2617            # Iterate through all columns and set via property setters
2618            for col_name in row.index:
2619                if col_name in skip_cols:
2620                    continue
2621                value = row[col_name]
2622                try:
2623                    if pd.isna(value):
2624                        continue
2625                except (TypeError, ValueError):
2626                    pass  # value is array-like; not NA, proceed
2627                
2628                # Set via property (public interface handles private attributes)
2629                # Don't save empty lists
2630                if isinstance(value, list) and len(value) == 0:
2631                    continue
2632                try:
2633                    setattr(mass_feature, col_name, value)
2634                except (AttributeError, TypeError):
2635                    pass  # Skip attributes that don't exist or can't be set
2636            
2637            # Set cluster_index if present
2638            if 'cluster' in row and pd.notna(row['cluster']):
2639                mass_feature.cluster_index = int(row['cluster'])
2640            
2641            regenerated_features[mass_feature.id] = mass_feature
2642        
2643        return regenerated_features
2644    
2645    def _save_lcms_objects_to_hdf5(self, cluster_mf_map, overwrite):
2646        """Save updated mass features for each LCMS object.
2647        
2648        This method implements a "selective update" strategy for mass features:
2649        - For mass features specified in cluster_mf_map (loaded representatives), we selectively
2650          update them by deleting their old entries and re-saving with new attributes.
2651        - Non-cluster features (not loaded) are never touched/overwritten.
2652        
2653        Note: EICs are NOT saved here. Induced feature EICs are saved at the collection level.
2654        
2655        Parameters
2656        ----------
2657        cluster_mf_map : dict
2658            Dictionary mapping sample_id to list of tuples (mf_id, cluster_id).
2659            This explicitly defines which mass features should be updated.
2660        overwrite : bool
2661            If True, allows overwriting of existing data. If False, skips if data exists.
2662        """
2663        for sample_id, lcms_obj in enumerate(self.mass_spectra_collection):
2664            hdf5_path = lcms_obj.file_location.with_suffix('.hdf5')
2665            
2666            if not hdf5_path.exists():
2667                # If HDF5 doesn't exist, we can't do selective update, raise error
2668                raise FileNotFoundError(
2669                    f"HDF5 file for LCMS object {lcms_obj.sample_name} not found at {hdf5_path}"
2670                )
2671            
2672            # Check if this sample has any loaded features in the map
2673            if sample_id not in cluster_mf_map or not cluster_mf_map[sample_id]:
2674                # Nothing loaded for this sample, nothing to update
2675                continue
2676            
2677            # Extract mf_ids from the map (cluster_mf_map contains tuples of (mf_id, cluster_id))
2678            mf_ids_to_update = [mf_id for mf_id, cluster_id in cluster_mf_map[sample_id]]
2679            
2680            # Perform selective update of mass features
2681            self._selective_update_mass_features(lcms_obj, hdf5_path, mf_ids_to_update, overwrite)
2682            
2683            # Save any new mass spectra that were added during processing
2684            self._save_new_mass_spectra(lcms_obj, hdf5_path, overwrite)
2685    
2686    def _save_new_mass_spectra(self, lcms_obj, hdf5_path, overwrite):
2687        """Save new mass spectra that were added during processing.
2688        
2689        This method checks what mass spectra are in lcms_obj._ms and saves any
2690        that aren't already in the HDF5 file's mass_spectra group. Uses the
2691        existing add_mass_spectrum_to_hdf5 method for consistency with original
2692        export logic.
2693        
2694        Parameters
2695        ----------
2696        lcms_obj : LCMSBase
2697            The LCMS object with potentially new mass spectra.
2698        hdf5_path : Path
2699            Path to the HDF5 file.
2700        overwrite : bool
2701            If True, allows overwriting existing spectra.
2702        """
2703        # Check if there are any mass spectra to save
2704        if not hasattr(lcms_obj, '_ms') or not lcms_obj._ms:
2705            return
2706        
2707        # Create an LCMS exporter instance for this LCMS object
2708        # This gives us access to add_mass_spectrum_to_hdf5 method inherited from HighResMassSpecExport
2709        # Turn hdf5_path into str without suffix for LCMSExport
2710        hdf5_path_str = str(hdf5_path.with_suffix(''))
2711        exporter = LCMSExport(
2712            out_file_path=hdf5_path_str,
2713            mass_spectra=lcms_obj
2714        )
2715        
2716        # Open HDF5 file and check existing mass spectra
2717        with h5py.File(hdf5_path, 'a') as hdf_handle:
2718            # Create mass_spectra group if it doesn't exist
2719            if 'mass_spectra' not in hdf_handle:
2720                ms_group = hdf_handle.create_group('mass_spectra')
2721                existing_scan_numbers = set()
2722            else:
2723                ms_group = hdf_handle['mass_spectra']
2724                existing_scan_numbers = set(int(k) for k in ms_group.keys())
2725            
2726            # Find new mass spectra (in _ms but not in HDF5)
2727            new_scan_numbers = set(lcms_obj._ms.keys()) - existing_scan_numbers
2728                        
2729            if not new_scan_numbers:
2730                return
2731            
2732            # Save new mass spectra using existing add_mass_spectrum_to_hdf5 method
2733            export_profile = lcms_obj.parameters.lc_ms.export_profile_spectra
2734            for scan_number in new_scan_numbers:
2735                mass_spec = lcms_obj._ms[scan_number]
2736                scan_group_name = str(scan_number)
2737                
2738                # Delete existing group if overwrite is True
2739                if scan_group_name in ms_group and overwrite:
2740                    del ms_group[scan_group_name]
2741                elif scan_group_name in ms_group:
2742                    continue
2743                
2744                # Use the existing method from HighResMassSpecExport
2745                exporter.add_mass_spectrum_to_hdf5(
2746                    hdf_handle=hdf_handle,
2747                    mass_spectrum=mass_spec,
2748                    group_key=scan_group_name,
2749                    mass_spectra_group=ms_group,
2750                    export_raw=export_profile
2751                )
2752    
2753    def _selective_update_mass_features(self, lcms_obj, hdf5_path, mf_ids_to_update, overwrite):
2754        """Selectively update mass features in HDF5 file.
2755        
2756        This method deletes only the mass features specified in mf_ids_to_update,
2757        then re-saves them with their potentially updated attributes. Non-cluster features
2758        in the HDF5 file are left untouched.
2759        
2760        Parameters
2761        ----------
2762        lcms_obj : LCMSBase
2763            The LCMS object with mass features to update.
2764        hdf5_path : Path
2765            Path to the HDF5 file.
2766        mf_ids_to_update : list of int
2767            List of mass feature IDs that should be updated. This explicitly defines
2768            which features were loaded and should be saved.
2769        overwrite : bool
2770            If True, allows overwriting. If False, skips if group exists.
2771        """
2772        if not mf_ids_to_update:
2773            return
2774        
2775        # Open HDF5 file and delete specified feature IDs, then re-save
2776        with h5py.File(hdf5_path, 'a') as hdf_handle:
2777            if 'mass_features' not in hdf_handle:
2778                return
2779            
2780            mf_group = hdf_handle['mass_features']
2781            
2782            # Delete features that are being updated
2783            for feature_id in mf_ids_to_update:
2784                feature_id_str = str(feature_id)
2785                if feature_id_str in mf_group:
2786                    del mf_group[feature_id_str]
2787            
2788            # Re-save updated features (only those that exist in mass_features dict)
2789            updated_features = {
2790                mf.id: mf for mf in lcms_obj.mass_features.values()
2791                if mf.id in mf_ids_to_update
2792            }
2793            
2794            if updated_features:
2795                LCMSExport._save_mass_features_dict_to_hdf5(
2796                    updated_features,
2797                    mf_group,
2798                    overwrite=overwrite
2799                )
2800    
ion_type_dict = {'M+': [{}, {}], '[M]+': [{}, {}], 'protonated': [{'H': 1}, {}], '[M+H]+': [{'H': 1}, {}], '[M+NH4]+': [{'N': 1, 'H': 4}, {}], '[M+Na]+': [{'Na': 1}, {}], '[M+K]+': [{'K': 1}, {}], '[M+2Na+Cl]+': [{'Na': 2, 'Cl': 1}, {}], '[M+2Na-H]+': [{'Na': 2}, {'H': 1}], '[M+C2H3Na2O2]+': [{'C': 2, 'H': 3, 'Na': 2, 'O': 2}, {}], '[M+C4H10N3]+': [{'C': 4, 'H': 10, 'N': 3}, {}], '[M+NH4+ACN]+': [{'C': 2, 'H': 7, 'N': 2}, {}], '[M+H-H2O]+': [{}, {'H': 1, 'O': 1}], 'de-protonated': [{}, {'H': 1}], '[M-H]-': [{}, {'H': 1}], '[M+Cl]-': [{'Cl': 1}, {}], '[M+HCOO]-': [{'C': 1, 'H': 1, 'O': 2}, {}], '[M+CH3COO]-': [{'C': 2, 'H': 3, 'O': 2}, {}], '[M+2NaAc+Cl]-': [{'Na': 2, 'C': 2, 'H': 3, 'O': 2, 'Cl': 1}, {}], '[M+K-2H]-': [{'K': 1}, {'H': 2}], '[M+Na-2H]-': [{'Na': 1}, {'H': 2}]}
class LowResGCMSExport:
 58class LowResGCMSExport:
 59    """A class to export low resolution GC-MS data.
 60
 61    This class provides methods to export low resolution GC-MS data to various formats such as Excel, CSV, HDF5, and Pandas DataFrame.
 62
 63    Parameters:
 64    ----------
 65    out_file_path : str
 66        The output file path.
 67    gcms : object
 68        The low resolution GCMS object.
 69
 70    Attributes:
 71    ----------
 72    output_file : Path
 73        The output file path as a Path object.
 74    gcms : object
 75        The low resolution GCMS object.
 76
 77    Methods:
 78    -------
 79    * get_pandas_df(id_label="corems:"). Get the exported data as a Pandas DataFrame.
 80    * get_json(nan=False, id_label="corems:"). Get the exported data as a JSON string.
 81    * to_pandas(write_metadata=True, id_label="corems:"). Export the data to a Pandas DataFrame and save it as a pickle file.
 82    * to_excel(write_mode='a', write_metadata=True, id_label="corems:"),
 83        Export the data to an Excel file.
 84    * to_csv(separate_output=False, write_mode="w", write_metadata=True, id_label="corems:").
 85        Export the data to a CSV file.
 86    * to_hdf(id_label="corems:").
 87        Export the data to an HDF5 file.
 88    * get_data_stats(gcms).
 89        Get statistics about the GCMS data.
 90
 91    """
 92
 93    def __init__(self, out_file_path, gcms):
 94        self.output_file = Path(out_file_path)
 95
 96        self.gcms = gcms
 97
 98        self._init_columns()
 99
100    def _init_columns(self):
101        """Initialize the column names for the exported data.
102
103        Returns:
104        -------
105        list
106            The list of column names.
107        """
108
109        columns = [
110            "Sample name",
111            "Peak Index",
112            "Retention Time",
113            "Retention Time Ref",
114            "Peak Height",
115            "Peak Area",
116            "Retention index",
117            "Retention index Ref",
118            "Retention Index Score",
119            "Similarity Score",
120            "Spectral Similarity Score",
121            "Compound Name",
122            "Chebi ID",
123            "Kegg Compound ID",
124            "Inchi",
125            "Inchi Key",
126            "Smiles",
127            "Molecular Formula",
128            "IUPAC Name",
129            "Traditional Name",
130            "Common Name",
131            "Derivatization",
132        ]
133
134        if self.gcms.molecular_search_settings.exploratory_mode:
135            columns.extend(
136                [
137                    "Weighted Cosine Correlation",
138                    "Cosine Correlation",
139                    "Stein Scott Similarity",
140                    "Pearson Correlation",
141                    "Spearman Correlation",
142                    "Kendall Tau Correlation",
143                    "Euclidean Distance",
144                    "Manhattan Distance",
145                    "Jaccard Distance",
146                    "DWT Correlation",
147                    "DFT Correlation",
148                ]
149            )
150
151            columns.extend(list(methods_name.values()))
152
153        return columns
154
155    def get_pandas_df(self, id_label="corems:"):
156        """Get the exported data as a Pandas DataFrame.
157
158        Parameters:
159        ----------
160        id_label : str, optional
161            The ID label for the data. Default is "corems:".
162
163        Returns:
164        -------
165        DataFrame
166            The exported data as a Pandas DataFrame.
167        """
168
169        columns = self._init_columns()
170
171        dict_data_list = self.get_list_dict_data(self.gcms)
172
173        df = DataFrame(dict_data_list, columns=columns)
174
175        df.name = self.gcms.sample_name
176
177        return df
178
179    def get_json(self, nan=False, id_label="corems:"):
180        """Get the exported data as a JSON string.
181
182        Parameters:
183        ----------
184        nan : bool, optional
185            Whether to include NaN values in the JSON string. Default is False.
186        id_label : str, optional
187            The ID label for the data. Default is "corems:".
188
189        """
190
191        import json
192
193        dict_data_list = self.get_list_dict_data(self.gcms)
194
195        return json.dumps(
196            dict_data_list, sort_keys=False, indent=4, separators=(",", ": ")
197        )
198
199    def to_pandas(self, write_metadata=True, id_label="corems:"):
200        """Export the data to a Pandas DataFrame and save it as a pickle file.
201
202        Parameters:
203        ----------
204        write_metadata : bool, optional
205            Whether to write metadata to the output file.
206        id_label : str, optional
207            The ID label for the data.
208        """
209
210        columns = self._init_columns()
211
212        dict_data_list = self.get_list_dict_data(self.gcms)
213
214        df = DataFrame(dict_data_list, columns=columns)
215
216        df.to_pickle(self.output_file.with_suffix(".pkl"))
217
218        if write_metadata:
219            self.write_settings(
220                self.output_file.with_suffix(".pkl"), self.gcms, id_label="corems:"
221            )
222
223    def to_excel(self, write_mode="a", write_metadata=True, id_label="corems:"):
224        """Export the data to an Excel file.
225
226        Parameters:
227        ----------
228        write_mode : str, optional
229            The write mode for the Excel file. Default is 'a' (append).
230        write_metadata : bool, optional
231            Whether to write metadata to the output file. Default is True.
232        id_label : str, optional
233            The ID label for the data. Default is "corems:".
234        """
235
236        out_put_path = self.output_file.with_suffix(".xlsx")
237
238        columns = self._init_columns()
239
240        dict_data_list = self.get_list_dict_data(self.gcms)
241
242        df = DataFrame(dict_data_list, columns=columns)
243
244        if write_mode == "a" and out_put_path.exists():
245            writer = ExcelWriter(out_put_path, engine="openpyxl")
246            # try to open an existing workbook
247            writer.book = load_workbook(out_put_path)
248            # copy existing sheets
249            writer.sheets = dict((ws.title, ws) for ws in writer.book.worksheets)
250            # read existing file
251            reader = read_excel(out_put_path)
252            # write out the new sheet
253            df.to_excel(writer, index=False, header=False, startrow=len(reader) + 1)
254
255            writer.close()
256        else:
257            df.to_excel(
258                self.output_file.with_suffix(".xlsx"), index=False, engine="openpyxl"
259            )
260
261        if write_metadata:
262            self.write_settings(out_put_path, self.gcms, id_label=id_label)
263
264    def to_csv(
265        self,
266        separate_output=False,
267        write_mode="w",
268        write_metadata=True,
269        id_label="corems:",
270    ):
271        """Export the data to a CSV file.
272
273        Parameters:
274        ----------
275        separate_output : bool, optional
276            Whether to separate the output into multiple files. Default is False.
277        write_mode : str, optional
278            The write mode for the CSV file. Default is 'w' (write).
279        write_metadata : bool, optional
280            Whether to write metadata to the output file. Default is True.
281        id_label : str, optional
282            The ID label for the data. Default is "corems:".
283        """
284
285        if separate_output:
286            # set write mode to write
287            # this mode will overwrite the file without warning
288            write_mode = "w"
289        else:
290            # set write mode to append
291            write_mode = "a"
292
293        columns = self._init_columns()
294
295        dict_data_list = self.get_list_dict_data(self.gcms)
296
297        out_put_path = self.output_file.with_suffix(".csv")
298
299        write_header = not out_put_path.exists()
300
301        try:
302            with open(out_put_path, write_mode, newline="") as csvfile:
303                writer = csv.DictWriter(csvfile, fieldnames=columns)
304                if write_header:
305                    writer.writeheader()
306                for data in dict_data_list:
307                    writer.writerow(data)
308
309            if write_metadata:
310                self.write_settings(out_put_path, self.gcms, id_label=id_label)
311
312        except IOError as ioerror:
313            print(ioerror)
314
315    def to_hdf(self, id_label="corems:"):
316        """Export the data to an HDF5 file.
317
318        Parameters:
319        ----------
320        id_label : str, optional
321            The ID label for the data. Default is "corems:".
322        """
323
324        # save sample at a time
325        def add_compound(gc_peak, compound_obj):
326            modifier = compound_obj.classify if compound_obj.classify else ""
327            compound_group = compound_obj.name.replace("/", "") + " " + modifier
328
329            if compound_group not in peak_group:
330                compound_group = peak_group.create_group(compound_group)
331
332                # compound_group.attrs["retention_time"] = compound_obj.retention_time
333                compound_group.attrs["retention_index"] = compound_obj.ri
334                compound_group.attrs["retention_index_score"] = compound_obj.ri_score
335                compound_group.attrs["spectral_similarity_score"] = (
336                    compound_obj.spectral_similarity_score
337                )
338                compound_group.attrs["similarity_score"] = compound_obj.similarity_score
339
340                compond_mz = compound_group.create_dataset(
341                    "mz", data=np.array(compound_obj.mz), dtype="f8"
342                )
343                compond_abundance = compound_group.create_dataset(
344                    "abundance", data=np.array(compound_obj.abundance), dtype="f8"
345                )
346
347                if self.gcms.molecular_search_settings.exploratory_mode:
348                    compound_group.attrs["Spectral Similarities"] = json.dumps(
349                        compound_obj.spectral_similarity_scores,
350                        sort_keys=False,
351                        indent=4,
352                        separators=(",", ":"),
353                    )
354            else:
355                warnings.warn("Skipping duplicate reference compound.")
356
357        import json
358        from datetime import datetime, timezone
359
360        import h5py
361        import numpy as np
362
363        output_path = self.output_file.with_suffix(".hdf5")
364
365        with h5py.File(output_path, "w") as hdf_handle:
366            timenow = str(datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z"))
367            hdf_handle.attrs["time_stamp"] = timenow
368            hdf_handle.attrs["data_structure"] = "gcms"
369            hdf_handle.attrs["analyzer"] = self.gcms.analyzer
370            hdf_handle.attrs["instrument_label"] = self.gcms.instrument_label
371
372            hdf_handle.attrs["sample_id"] = "self.gcms.id"
373            hdf_handle.attrs["sample_name"] = self.gcms.sample_name
374            hdf_handle.attrs["input_data"] = str(self.gcms.file_location)
375            hdf_handle.attrs["output_data"] = str(output_path)
376            hdf_handle.attrs["output_data_id"] = id_label + uuid.uuid4().hex
377            hdf_handle.attrs["corems_version"] = __version__
378
379            hdf_handle.attrs["Stats"] = json.dumps(
380                self.get_data_stats(self.gcms),
381                sort_keys=False,
382                indent=4,
383                separators=(",", ": "),
384            )
385            hdf_handle.attrs["Calibration"] = json.dumps(
386                self.get_calibration_stats(self.gcms, id_label),
387                sort_keys=False,
388                indent=4,
389                separators=(",", ": "),
390            )
391            hdf_handle.attrs["Blank"] = json.dumps(
392                self.get_blank_stats(self.gcms),
393                sort_keys=False,
394                indent=4,
395                separators=(",", ": "),
396            )
397
398            corems_dict_setting = parameter_to_dict.get_dict_data_gcms(self.gcms)
399            hdf_handle.attrs["CoreMSParameters"] = json.dumps(
400                corems_dict_setting, sort_keys=False, indent=4, separators=(",", ": ")
401            )
402
403            scans_dataset = hdf_handle.create_dataset(
404                "scans", data=np.array(self.gcms.scans_number), dtype="f8"
405            )
406            rt_dataset = hdf_handle.create_dataset(
407                "rt", data=np.array(self.gcms.retention_time), dtype="f8"
408            )
409            tic_dataset = hdf_handle.create_dataset(
410                "tic", data=np.array(self.gcms.tic), dtype="f8"
411            )
412            processed_tic_dataset = hdf_handle.create_dataset(
413                "processed_tic", data=np.array(self.gcms.processed_tic), dtype="f8"
414            )
415
416            output_score_method = (
417                self.gcms.molecular_search_settings.output_score_method
418            )
419
420            for gc_peak in self.gcms:
421                # print(gc_peak.retention_time)
422                # print(gc_peak.tic)
423
424                # check if there is a compound candidate
425                peak_group = hdf_handle.create_group(str(gc_peak.retention_time))
426                peak_group.attrs["deconvolution"] = int(
427                    self.gcms.chromatogram_settings.use_deconvolution
428                )
429
430                peak_group.attrs["start_scan"] = gc_peak.start_scan
431                peak_group.attrs["apex_scan"] = gc_peak.apex_scan
432                peak_group.attrs["final_scan"] = gc_peak.final_scan
433
434                peak_group.attrs["retention_index"] = gc_peak.ri
435                peak_group.attrs["retention_time"] = gc_peak.retention_time
436                peak_group.attrs["area"] = gc_peak.area
437
438                mz = peak_group.create_dataset(
439                    "mz", data=np.array(gc_peak.mass_spectrum.mz_exp), dtype="f8"
440                )
441                abundance = peak_group.create_dataset(
442                    "abundance",
443                    data=np.array(gc_peak.mass_spectrum.abundance),
444                    dtype="f8",
445                )
446
447                if gc_peak:
448                    if output_score_method == "highest_sim_score":
449                        compound_obj = gc_peak.highest_score_compound
450                        add_compound(gc_peak, compound_obj)
451
452                    elif output_score_method == "highest_ss":
453                        compound_obj = gc_peak.highest_ss_compound
454                        add_compound(gc_peak, compound_obj)
455
456                    else:
457                        for compound_obj in gc_peak:
458                            add_compound(gc_peak, compound_obj)
459
460    def get_data_stats(self, gcms):
461        """Get statistics about the GCMS data.
462
463        Parameters:
464        ----------
465        gcms : object
466            The low resolution GCMS object.
467
468        Returns:
469        -------
470        dict
471            A dictionary containing the data statistics.
472        """
473
474        matched_peaks = gcms.matched_peaks
475        no_matched_peaks = gcms.no_matched_peaks
476        unique_metabolites = gcms.unique_metabolites
477
478        peak_matchs_above_0p85 = 0
479        unique_peak_match_above_0p85 = 0
480        for match_peak in matched_peaks:
481            gc_peak_above_85 = 0
482            matches_above_85 = list(
483                filter(lambda m: m.similarity_score >= 0.85, match_peak)
484            )
485            if matches_above_85:
486                peak_matchs_above_0p85 += 1
487            if len(matches_above_85) == 1:
488                unique_peak_match_above_0p85 += 1
489
490        data_stats = {}
491        data_stats["average_signal_noise"] = "ni"
492        data_stats["chromatogram_dynamic_range"] = gcms.dynamic_range
493        data_stats["total_number_peaks"] = len(gcms)
494        data_stats["total_peaks_matched"] = len(matched_peaks)
495        data_stats["total_peaks_without_matches"] = len(no_matched_peaks)
496        data_stats["total_matches_above_similarity_score_0.85"] = peak_matchs_above_0p85
497        data_stats["single_matches_above_similarity_score_0.85"] = (
498            unique_peak_match_above_0p85
499        )
500        data_stats["unique_metabolites"] = len(unique_metabolites)
501
502        return data_stats
503
504    def get_calibration_stats(self, gcms, id_label):
505        """Get statistics about the GC-MS calibration.
506
507        Parameters:
508        ----------
509        """
510        calibration_parameters = {}
511
512        calibration_parameters["calibration_rt_ri_pairs_ref"] = gcms.ri_pairs_ref
513        calibration_parameters["data_url"] = str(gcms.cal_file_path)
514        calibration_parameters["has_input"] = id_label + corems_md5(gcms.cal_file_path)
515        calibration_parameters["data_name"] = str(gcms.cal_file_path.stem)
516        calibration_parameters["calibration_method"] = ""
517
518        return calibration_parameters
519
520    def get_blank_stats(self, gcms):
521        """Get statistics about the GC-MS blank."""
522        blank_parameters = {}
523
524        blank_parameters["data_name"] = "ni"
525        blank_parameters["blank_id"] = "ni"
526        blank_parameters["data_url"] = "ni"
527        blank_parameters["has_input"] = "ni"
528        blank_parameters["common_features_to_blank"] = "ni"
529
530        return blank_parameters
531
532    def get_instrument_metadata(self, gcms):
533        """Get metadata about the GC-MS instrument."""
534        instrument_metadata = {}
535
536        instrument_metadata["analyzer"] = gcms.analyzer
537        instrument_metadata["instrument_label"] = gcms.instrument_label
538        instrument_metadata["instrument_id"] = uuid.uuid4().hex
539
540        return instrument_metadata
541
542    def get_data_metadata(self, gcms, id_label, output_path):
543        """Get metadata about the GC-MS data.
544
545        Parameters:
546        ----------
547        gcms : object
548            The low resolution GCMS object.
549        id_label : str
550            The ID label for the data.
551        output_path : str
552            The output file path.
553
554        Returns:
555        -------
556        dict
557            A dictionary containing the data metadata.
558        """
559        if isinstance(output_path, str):
560            output_path = Path(output_path)
561
562        paramaters_path = output_path.with_suffix(".json")
563
564        if paramaters_path.exists():
565            with paramaters_path.open() as current_param:
566                metadata = json.load(current_param)
567                data_metadata = metadata.get("Data")
568        else:
569            data_metadata = {}
570            data_metadata["data_name"] = []
571            data_metadata["input_data_url"] = []
572            data_metadata["has_input"] = []
573
574        data_metadata["data_name"].append(gcms.sample_name)
575        data_metadata["input_data_url"].append(str(gcms.file_location))
576        data_metadata["has_input"].append(id_label + corems_md5(gcms.file_location))
577
578        data_metadata["output_data_name"] = str(output_path.stem)
579        data_metadata["output_data_url"] = str(output_path)
580        data_metadata["has_output"] = id_label + corems_md5(output_path)
581
582        return data_metadata
583
584    def get_parameters_json(self, gcms, id_label, output_path):
585        """Get the parameters as a JSON string.
586
587        Parameters:
588        ----------
589        gcms : GCMS object
590            The low resolution GCMS object.
591        id_label : str
592            The ID label for the data.
593        output_path : str
594            The output file path.
595
596        Returns:
597        -------
598        str
599            The parameters as a JSON string.
600        """
601
602        output_parameters_dict = {}
603        output_parameters_dict["Data"] = self.get_data_metadata(
604            gcms, id_label, output_path
605        )
606        output_parameters_dict["Stats"] = self.get_data_stats(gcms)
607        output_parameters_dict["Calibration"] = self.get_calibration_stats(
608            gcms, id_label
609        )
610        output_parameters_dict["Blank"] = self.get_blank_stats(gcms)
611        output_parameters_dict["Instrument"] = self.get_instrument_metadata(gcms)
612        corems_dict_setting = parameter_to_dict.get_dict_data_gcms(gcms)
613        corems_dict_setting["corems_version"] = __version__
614        output_parameters_dict["CoreMSParameters"] = corems_dict_setting
615        output_parameters_dict["has_metabolite"] = gcms.metabolites_data
616        output = json.dumps(
617            output_parameters_dict, sort_keys=False, indent=4, separators=(",", ": ")
618        )
619
620        return output
621
622    def write_settings(self, output_path, gcms, id_label="emsl:"):
623        """Write the settings to a JSON file.
624
625        Parameters:
626        ----------
627        output_path : str
628            The output file path.
629        gcms : GCMS object
630            The low resolution GCMS object.
631        id_label : str
632            The ID label for the data. Default is "emsl:".
633
634        """
635
636        output = self.get_parameters_json(gcms, id_label, output_path)
637
638        with open(
639            output_path.with_suffix(".json"),
640            "w",
641            encoding="utf8",
642        ) as outfile:
643            outfile.write(output)
644
645    def get_list_dict_data(self, gcms, include_no_match=True, no_match_inline=False):
646        """Get the exported data as a list of dictionaries.
647
648        Parameters:
649        ----------
650        gcms : object
651            The low resolution GCMS object.
652        include_no_match : bool, optional
653            Whether to include no match data. Default is True.
654        no_match_inline : bool, optional
655            Whether to include no match data inline. Default is False.
656
657        Returns:
658        -------
659        list
660            The exported data as a list of dictionaries.
661        """
662
663        output_score_method = gcms.molecular_search_settings.output_score_method
664
665        dict_data_list = []
666
667        def add_match_dict_data():
668            derivatization = "{}:{}:{}".format(
669                compound_obj.classify,
670                compound_obj.derivativenum,
671                compound_obj.derivatization,
672            )
673            out_dict = {
674                "Sample name": gcms.sample_name,
675                "Peak Index": gcpeak_index,
676                "Retention Time": gc_peak.retention_time,
677                "Retention Time Ref": compound_obj.retention_time,
678                "Peak Height": gc_peak.tic,
679                "Peak Area": gc_peak.area,
680                "Retention index": gc_peak.ri,
681                "Retention index Ref": compound_obj.ri,
682                "Retention Index Score": compound_obj.ri_score,
683                "Spectral Similarity Score": compound_obj.spectral_similarity_score,
684                "Similarity Score": compound_obj.similarity_score,
685                "Compound Name": compound_obj.name,
686                "Chebi ID": compound_obj.metadata.chebi,
687                "Kegg Compound ID": compound_obj.metadata.kegg,
688                "Inchi": compound_obj.metadata.inchi,
689                "Inchi Key": compound_obj.metadata.inchikey,
690                "Smiles": compound_obj.metadata.smiles,
691                "Molecular Formula": compound_obj.formula,
692                "IUPAC Name": compound_obj.metadata.iupac_name,
693                "Traditional Name": compound_obj.metadata.traditional_name,
694                "Common Name": compound_obj.metadata.common_name,
695                "Derivatization": derivatization,
696            }
697
698            if self.gcms.molecular_search_settings.exploratory_mode:
699                out_dict.update(
700                    {
701                        "Weighted Cosine Correlation": compound_obj.spectral_similarity_scores.get(
702                            "weighted_cosine_correlation"
703                        ),
704                        "Cosine Correlation": compound_obj.spectral_similarity_scores.get(
705                            "cosine_correlation"
706                        ),
707                        "Stein Scott Similarity": compound_obj.spectral_similarity_scores.get(
708                            "stein_scott_similarity"
709                        ),
710                        "Pearson Correlation": compound_obj.spectral_similarity_scores.get(
711                            "pearson_correlation"
712                        ),
713                        "Spearman Correlation": compound_obj.spectral_similarity_scores.get(
714                            "spearman_correlation"
715                        ),
716                        "Kendall Tau Correlation": compound_obj.spectral_similarity_scores.get(
717                            "kendall_tau_correlation"
718                        ),
719                        "DFT Correlation": compound_obj.spectral_similarity_scores.get(
720                            "dft_correlation"
721                        ),
722                        "DWT Correlation": compound_obj.spectral_similarity_scores.get(
723                            "dwt_correlation"
724                        ),
725                        "Euclidean Distance": compound_obj.spectral_similarity_scores.get(
726                            "euclidean_distance"
727                        ),
728                        "Manhattan Distance": compound_obj.spectral_similarity_scores.get(
729                            "manhattan_distance"
730                        ),
731                        "Jaccard Distance": compound_obj.spectral_similarity_scores.get(
732                            "jaccard_distance"
733                        ),
734                    }
735                )
736                for method in methods_name:
737                    out_dict[methods_name.get(method)] = (
738                        compound_obj.spectral_similarity_scores.get(method)
739                    )
740
741            dict_data_list.append(out_dict)
742
743        def add_no_match_dict_data():
744            dict_data_list.append(
745                {
746                    "Sample name": gcms.sample_name,
747                    "Peak Index": gcpeak_index,
748                    "Retention Time": gc_peak.retention_time,
749                    "Peak Height": gc_peak.tic,
750                    "Peak Area": gc_peak.area,
751                    "Retention index": gc_peak.ri,
752                }
753            )
754
755        for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
756            # check if there is a compound candidate
757            if gc_peak:
758                if output_score_method == "highest_sim_score":
759                    compound_obj = gc_peak.highest_score_compound
760                    add_match_dict_data()
761
762                elif output_score_method == "highest_ss":
763                    compound_obj = gc_peak.highest_ss_compound
764                    add_match_dict_data()
765
766                else:
767                    for compound_obj in gc_peak:
768                        add_match_dict_data()  # add monoisotopic peak
769
770            else:
771                # include not_match
772                if include_no_match and no_match_inline:
773                    add_no_match_dict_data()
774
775        if include_no_match and not no_match_inline:
776            for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
777                if not gc_peak:
778                    add_no_match_dict_data()
779
780        return dict_data_list

A class to export low resolution GC-MS data.

This class provides methods to export low resolution GC-MS data to various formats such as Excel, CSV, HDF5, and Pandas DataFrame.

Parameters:

out_file_path : str The output file path. gcms : object The low resolution GCMS object.

Attributes:

output_file : Path The output file path as a Path object. gcms : object The low resolution GCMS object.

Methods:

  • get_pandas_df(id_label="corems:"). Get the exported data as a Pandas DataFrame.
  • get_json(nan=False, id_label="corems:"). Get the exported data as a JSON string.
  • to_pandas(write_metadata=True, id_label="corems:"). Export the data to a Pandas DataFrame and save it as a pickle file.
  • to_excel(write_mode='a', write_metadata=True, id_label="corems:"), Export the data to an Excel file.
  • to_csv(separate_output=False, write_mode="w", write_metadata=True, id_label="corems:"). Export the data to a CSV file.
  • to_hdf(id_label="corems:"). Export the data to an HDF5 file.
  • get_data_stats(gcms). Get statistics about the GCMS data.
LowResGCMSExport(out_file_path, gcms)
93    def __init__(self, out_file_path, gcms):
94        self.output_file = Path(out_file_path)
95
96        self.gcms = gcms
97
98        self._init_columns()
output_file
gcms
def get_pandas_df(self, id_label='corems:'):
155    def get_pandas_df(self, id_label="corems:"):
156        """Get the exported data as a Pandas DataFrame.
157
158        Parameters:
159        ----------
160        id_label : str, optional
161            The ID label for the data. Default is "corems:".
162
163        Returns:
164        -------
165        DataFrame
166            The exported data as a Pandas DataFrame.
167        """
168
169        columns = self._init_columns()
170
171        dict_data_list = self.get_list_dict_data(self.gcms)
172
173        df = DataFrame(dict_data_list, columns=columns)
174
175        df.name = self.gcms.sample_name
176
177        return df

Get the exported data as a Pandas DataFrame.

Parameters:

id_label : str, optional The ID label for the data. Default is "corems:".

Returns:

DataFrame The exported data as a Pandas DataFrame.

def get_json(self, nan=False, id_label='corems:'):
179    def get_json(self, nan=False, id_label="corems:"):
180        """Get the exported data as a JSON string.
181
182        Parameters:
183        ----------
184        nan : bool, optional
185            Whether to include NaN values in the JSON string. Default is False.
186        id_label : str, optional
187            The ID label for the data. Default is "corems:".
188
189        """
190
191        import json
192
193        dict_data_list = self.get_list_dict_data(self.gcms)
194
195        return json.dumps(
196            dict_data_list, sort_keys=False, indent=4, separators=(",", ": ")
197        )

Get the exported data as a JSON string.

Parameters:

nan : bool, optional Whether to include NaN values in the JSON string. Default is False. id_label : str, optional The ID label for the data. Default is "corems:".

def to_pandas(self, write_metadata=True, id_label='corems:'):
199    def to_pandas(self, write_metadata=True, id_label="corems:"):
200        """Export the data to a Pandas DataFrame and save it as a pickle file.
201
202        Parameters:
203        ----------
204        write_metadata : bool, optional
205            Whether to write metadata to the output file.
206        id_label : str, optional
207            The ID label for the data.
208        """
209
210        columns = self._init_columns()
211
212        dict_data_list = self.get_list_dict_data(self.gcms)
213
214        df = DataFrame(dict_data_list, columns=columns)
215
216        df.to_pickle(self.output_file.with_suffix(".pkl"))
217
218        if write_metadata:
219            self.write_settings(
220                self.output_file.with_suffix(".pkl"), self.gcms, id_label="corems:"
221            )

Export the data to a Pandas DataFrame and save it as a pickle file.

Parameters:

write_metadata : bool, optional Whether to write metadata to the output file. id_label : str, optional The ID label for the data.

def to_excel(self, write_mode='a', write_metadata=True, id_label='corems:'):
223    def to_excel(self, write_mode="a", write_metadata=True, id_label="corems:"):
224        """Export the data to an Excel file.
225
226        Parameters:
227        ----------
228        write_mode : str, optional
229            The write mode for the Excel file. Default is 'a' (append).
230        write_metadata : bool, optional
231            Whether to write metadata to the output file. Default is True.
232        id_label : str, optional
233            The ID label for the data. Default is "corems:".
234        """
235
236        out_put_path = self.output_file.with_suffix(".xlsx")
237
238        columns = self._init_columns()
239
240        dict_data_list = self.get_list_dict_data(self.gcms)
241
242        df = DataFrame(dict_data_list, columns=columns)
243
244        if write_mode == "a" and out_put_path.exists():
245            writer = ExcelWriter(out_put_path, engine="openpyxl")
246            # try to open an existing workbook
247            writer.book = load_workbook(out_put_path)
248            # copy existing sheets
249            writer.sheets = dict((ws.title, ws) for ws in writer.book.worksheets)
250            # read existing file
251            reader = read_excel(out_put_path)
252            # write out the new sheet
253            df.to_excel(writer, index=False, header=False, startrow=len(reader) + 1)
254
255            writer.close()
256        else:
257            df.to_excel(
258                self.output_file.with_suffix(".xlsx"), index=False, engine="openpyxl"
259            )
260
261        if write_metadata:
262            self.write_settings(out_put_path, self.gcms, id_label=id_label)

Export the data to an Excel file.

Parameters:

write_mode : str, optional The write mode for the Excel file. Default is 'a' (append). write_metadata : bool, optional Whether to write metadata to the output file. Default is True. id_label : str, optional The ID label for the data. Default is "corems:".

def to_csv( self, separate_output=False, write_mode='w', write_metadata=True, id_label='corems:'):
264    def to_csv(
265        self,
266        separate_output=False,
267        write_mode="w",
268        write_metadata=True,
269        id_label="corems:",
270    ):
271        """Export the data to a CSV file.
272
273        Parameters:
274        ----------
275        separate_output : bool, optional
276            Whether to separate the output into multiple files. Default is False.
277        write_mode : str, optional
278            The write mode for the CSV file. Default is 'w' (write).
279        write_metadata : bool, optional
280            Whether to write metadata to the output file. Default is True.
281        id_label : str, optional
282            The ID label for the data. Default is "corems:".
283        """
284
285        if separate_output:
286            # set write mode to write
287            # this mode will overwrite the file without warning
288            write_mode = "w"
289        else:
290            # set write mode to append
291            write_mode = "a"
292
293        columns = self._init_columns()
294
295        dict_data_list = self.get_list_dict_data(self.gcms)
296
297        out_put_path = self.output_file.with_suffix(".csv")
298
299        write_header = not out_put_path.exists()
300
301        try:
302            with open(out_put_path, write_mode, newline="") as csvfile:
303                writer = csv.DictWriter(csvfile, fieldnames=columns)
304                if write_header:
305                    writer.writeheader()
306                for data in dict_data_list:
307                    writer.writerow(data)
308
309            if write_metadata:
310                self.write_settings(out_put_path, self.gcms, id_label=id_label)
311
312        except IOError as ioerror:
313            print(ioerror)

Export the data to a CSV file.

Parameters:

separate_output : bool, optional Whether to separate the output into multiple files. Default is False. write_mode : str, optional The write mode for the CSV file. Default is 'w' (write). write_metadata : bool, optional Whether to write metadata to the output file. Default is True. id_label : str, optional The ID label for the data. Default is "corems:".

def to_hdf(self, id_label='corems:'):
315    def to_hdf(self, id_label="corems:"):
316        """Export the data to an HDF5 file.
317
318        Parameters:
319        ----------
320        id_label : str, optional
321            The ID label for the data. Default is "corems:".
322        """
323
324        # save sample at a time
325        def add_compound(gc_peak, compound_obj):
326            modifier = compound_obj.classify if compound_obj.classify else ""
327            compound_group = compound_obj.name.replace("/", "") + " " + modifier
328
329            if compound_group not in peak_group:
330                compound_group = peak_group.create_group(compound_group)
331
332                # compound_group.attrs["retention_time"] = compound_obj.retention_time
333                compound_group.attrs["retention_index"] = compound_obj.ri
334                compound_group.attrs["retention_index_score"] = compound_obj.ri_score
335                compound_group.attrs["spectral_similarity_score"] = (
336                    compound_obj.spectral_similarity_score
337                )
338                compound_group.attrs["similarity_score"] = compound_obj.similarity_score
339
340                compond_mz = compound_group.create_dataset(
341                    "mz", data=np.array(compound_obj.mz), dtype="f8"
342                )
343                compond_abundance = compound_group.create_dataset(
344                    "abundance", data=np.array(compound_obj.abundance), dtype="f8"
345                )
346
347                if self.gcms.molecular_search_settings.exploratory_mode:
348                    compound_group.attrs["Spectral Similarities"] = json.dumps(
349                        compound_obj.spectral_similarity_scores,
350                        sort_keys=False,
351                        indent=4,
352                        separators=(",", ":"),
353                    )
354            else:
355                warnings.warn("Skipping duplicate reference compound.")
356
357        import json
358        from datetime import datetime, timezone
359
360        import h5py
361        import numpy as np
362
363        output_path = self.output_file.with_suffix(".hdf5")
364
365        with h5py.File(output_path, "w") as hdf_handle:
366            timenow = str(datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z"))
367            hdf_handle.attrs["time_stamp"] = timenow
368            hdf_handle.attrs["data_structure"] = "gcms"
369            hdf_handle.attrs["analyzer"] = self.gcms.analyzer
370            hdf_handle.attrs["instrument_label"] = self.gcms.instrument_label
371
372            hdf_handle.attrs["sample_id"] = "self.gcms.id"
373            hdf_handle.attrs["sample_name"] = self.gcms.sample_name
374            hdf_handle.attrs["input_data"] = str(self.gcms.file_location)
375            hdf_handle.attrs["output_data"] = str(output_path)
376            hdf_handle.attrs["output_data_id"] = id_label + uuid.uuid4().hex
377            hdf_handle.attrs["corems_version"] = __version__
378
379            hdf_handle.attrs["Stats"] = json.dumps(
380                self.get_data_stats(self.gcms),
381                sort_keys=False,
382                indent=4,
383                separators=(",", ": "),
384            )
385            hdf_handle.attrs["Calibration"] = json.dumps(
386                self.get_calibration_stats(self.gcms, id_label),
387                sort_keys=False,
388                indent=4,
389                separators=(",", ": "),
390            )
391            hdf_handle.attrs["Blank"] = json.dumps(
392                self.get_blank_stats(self.gcms),
393                sort_keys=False,
394                indent=4,
395                separators=(",", ": "),
396            )
397
398            corems_dict_setting = parameter_to_dict.get_dict_data_gcms(self.gcms)
399            hdf_handle.attrs["CoreMSParameters"] = json.dumps(
400                corems_dict_setting, sort_keys=False, indent=4, separators=(",", ": ")
401            )
402
403            scans_dataset = hdf_handle.create_dataset(
404                "scans", data=np.array(self.gcms.scans_number), dtype="f8"
405            )
406            rt_dataset = hdf_handle.create_dataset(
407                "rt", data=np.array(self.gcms.retention_time), dtype="f8"
408            )
409            tic_dataset = hdf_handle.create_dataset(
410                "tic", data=np.array(self.gcms.tic), dtype="f8"
411            )
412            processed_tic_dataset = hdf_handle.create_dataset(
413                "processed_tic", data=np.array(self.gcms.processed_tic), dtype="f8"
414            )
415
416            output_score_method = (
417                self.gcms.molecular_search_settings.output_score_method
418            )
419
420            for gc_peak in self.gcms:
421                # print(gc_peak.retention_time)
422                # print(gc_peak.tic)
423
424                # check if there is a compound candidate
425                peak_group = hdf_handle.create_group(str(gc_peak.retention_time))
426                peak_group.attrs["deconvolution"] = int(
427                    self.gcms.chromatogram_settings.use_deconvolution
428                )
429
430                peak_group.attrs["start_scan"] = gc_peak.start_scan
431                peak_group.attrs["apex_scan"] = gc_peak.apex_scan
432                peak_group.attrs["final_scan"] = gc_peak.final_scan
433
434                peak_group.attrs["retention_index"] = gc_peak.ri
435                peak_group.attrs["retention_time"] = gc_peak.retention_time
436                peak_group.attrs["area"] = gc_peak.area
437
438                mz = peak_group.create_dataset(
439                    "mz", data=np.array(gc_peak.mass_spectrum.mz_exp), dtype="f8"
440                )
441                abundance = peak_group.create_dataset(
442                    "abundance",
443                    data=np.array(gc_peak.mass_spectrum.abundance),
444                    dtype="f8",
445                )
446
447                if gc_peak:
448                    if output_score_method == "highest_sim_score":
449                        compound_obj = gc_peak.highest_score_compound
450                        add_compound(gc_peak, compound_obj)
451
452                    elif output_score_method == "highest_ss":
453                        compound_obj = gc_peak.highest_ss_compound
454                        add_compound(gc_peak, compound_obj)
455
456                    else:
457                        for compound_obj in gc_peak:
458                            add_compound(gc_peak, compound_obj)

Export the data to an HDF5 file.

Parameters:

id_label : str, optional The ID label for the data. Default is "corems:".

def get_data_stats(self, gcms):
460    def get_data_stats(self, gcms):
461        """Get statistics about the GCMS data.
462
463        Parameters:
464        ----------
465        gcms : object
466            The low resolution GCMS object.
467
468        Returns:
469        -------
470        dict
471            A dictionary containing the data statistics.
472        """
473
474        matched_peaks = gcms.matched_peaks
475        no_matched_peaks = gcms.no_matched_peaks
476        unique_metabolites = gcms.unique_metabolites
477
478        peak_matchs_above_0p85 = 0
479        unique_peak_match_above_0p85 = 0
480        for match_peak in matched_peaks:
481            gc_peak_above_85 = 0
482            matches_above_85 = list(
483                filter(lambda m: m.similarity_score >= 0.85, match_peak)
484            )
485            if matches_above_85:
486                peak_matchs_above_0p85 += 1
487            if len(matches_above_85) == 1:
488                unique_peak_match_above_0p85 += 1
489
490        data_stats = {}
491        data_stats["average_signal_noise"] = "ni"
492        data_stats["chromatogram_dynamic_range"] = gcms.dynamic_range
493        data_stats["total_number_peaks"] = len(gcms)
494        data_stats["total_peaks_matched"] = len(matched_peaks)
495        data_stats["total_peaks_without_matches"] = len(no_matched_peaks)
496        data_stats["total_matches_above_similarity_score_0.85"] = peak_matchs_above_0p85
497        data_stats["single_matches_above_similarity_score_0.85"] = (
498            unique_peak_match_above_0p85
499        )
500        data_stats["unique_metabolites"] = len(unique_metabolites)
501
502        return data_stats

Get statistics about the GCMS data.

Parameters:

gcms : object The low resolution GCMS object.

Returns:

dict A dictionary containing the data statistics.

def get_calibration_stats(self, gcms, id_label):
504    def get_calibration_stats(self, gcms, id_label):
505        """Get statistics about the GC-MS calibration.
506
507        Parameters:
508        ----------
509        """
510        calibration_parameters = {}
511
512        calibration_parameters["calibration_rt_ri_pairs_ref"] = gcms.ri_pairs_ref
513        calibration_parameters["data_url"] = str(gcms.cal_file_path)
514        calibration_parameters["has_input"] = id_label + corems_md5(gcms.cal_file_path)
515        calibration_parameters["data_name"] = str(gcms.cal_file_path.stem)
516        calibration_parameters["calibration_method"] = ""
517
518        return calibration_parameters

Get statistics about the GC-MS calibration.

Parameters:

def get_blank_stats(self, gcms):
520    def get_blank_stats(self, gcms):
521        """Get statistics about the GC-MS blank."""
522        blank_parameters = {}
523
524        blank_parameters["data_name"] = "ni"
525        blank_parameters["blank_id"] = "ni"
526        blank_parameters["data_url"] = "ni"
527        blank_parameters["has_input"] = "ni"
528        blank_parameters["common_features_to_blank"] = "ni"
529
530        return blank_parameters

Get statistics about the GC-MS blank.

def get_instrument_metadata(self, gcms):
532    def get_instrument_metadata(self, gcms):
533        """Get metadata about the GC-MS instrument."""
534        instrument_metadata = {}
535
536        instrument_metadata["analyzer"] = gcms.analyzer
537        instrument_metadata["instrument_label"] = gcms.instrument_label
538        instrument_metadata["instrument_id"] = uuid.uuid4().hex
539
540        return instrument_metadata

Get metadata about the GC-MS instrument.

def get_data_metadata(self, gcms, id_label, output_path):
542    def get_data_metadata(self, gcms, id_label, output_path):
543        """Get metadata about the GC-MS data.
544
545        Parameters:
546        ----------
547        gcms : object
548            The low resolution GCMS object.
549        id_label : str
550            The ID label for the data.
551        output_path : str
552            The output file path.
553
554        Returns:
555        -------
556        dict
557            A dictionary containing the data metadata.
558        """
559        if isinstance(output_path, str):
560            output_path = Path(output_path)
561
562        paramaters_path = output_path.with_suffix(".json")
563
564        if paramaters_path.exists():
565            with paramaters_path.open() as current_param:
566                metadata = json.load(current_param)
567                data_metadata = metadata.get("Data")
568        else:
569            data_metadata = {}
570            data_metadata["data_name"] = []
571            data_metadata["input_data_url"] = []
572            data_metadata["has_input"] = []
573
574        data_metadata["data_name"].append(gcms.sample_name)
575        data_metadata["input_data_url"].append(str(gcms.file_location))
576        data_metadata["has_input"].append(id_label + corems_md5(gcms.file_location))
577
578        data_metadata["output_data_name"] = str(output_path.stem)
579        data_metadata["output_data_url"] = str(output_path)
580        data_metadata["has_output"] = id_label + corems_md5(output_path)
581
582        return data_metadata

Get metadata about the GC-MS data.

Parameters:

gcms : object The low resolution GCMS object. id_label : str The ID label for the data. output_path : str The output file path.

Returns:

dict A dictionary containing the data metadata.

def get_parameters_json(self, gcms, id_label, output_path):
584    def get_parameters_json(self, gcms, id_label, output_path):
585        """Get the parameters as a JSON string.
586
587        Parameters:
588        ----------
589        gcms : GCMS object
590            The low resolution GCMS object.
591        id_label : str
592            The ID label for the data.
593        output_path : str
594            The output file path.
595
596        Returns:
597        -------
598        str
599            The parameters as a JSON string.
600        """
601
602        output_parameters_dict = {}
603        output_parameters_dict["Data"] = self.get_data_metadata(
604            gcms, id_label, output_path
605        )
606        output_parameters_dict["Stats"] = self.get_data_stats(gcms)
607        output_parameters_dict["Calibration"] = self.get_calibration_stats(
608            gcms, id_label
609        )
610        output_parameters_dict["Blank"] = self.get_blank_stats(gcms)
611        output_parameters_dict["Instrument"] = self.get_instrument_metadata(gcms)
612        corems_dict_setting = parameter_to_dict.get_dict_data_gcms(gcms)
613        corems_dict_setting["corems_version"] = __version__
614        output_parameters_dict["CoreMSParameters"] = corems_dict_setting
615        output_parameters_dict["has_metabolite"] = gcms.metabolites_data
616        output = json.dumps(
617            output_parameters_dict, sort_keys=False, indent=4, separators=(",", ": ")
618        )
619
620        return output

Get the parameters as a JSON string.

Parameters:

gcms : GCMS object The low resolution GCMS object. id_label : str The ID label for the data. output_path : str The output file path.

Returns:

str The parameters as a JSON string.

def write_settings(self, output_path, gcms, id_label='emsl:'):
622    def write_settings(self, output_path, gcms, id_label="emsl:"):
623        """Write the settings to a JSON file.
624
625        Parameters:
626        ----------
627        output_path : str
628            The output file path.
629        gcms : GCMS object
630            The low resolution GCMS object.
631        id_label : str
632            The ID label for the data. Default is "emsl:".
633
634        """
635
636        output = self.get_parameters_json(gcms, id_label, output_path)
637
638        with open(
639            output_path.with_suffix(".json"),
640            "w",
641            encoding="utf8",
642        ) as outfile:
643            outfile.write(output)

Write the settings to a JSON file.

Parameters:

output_path : str The output file path. gcms : GCMS object The low resolution GCMS object. id_label : str The ID label for the data. Default is "emsl:".

def get_list_dict_data(self, gcms, include_no_match=True, no_match_inline=False):
645    def get_list_dict_data(self, gcms, include_no_match=True, no_match_inline=False):
646        """Get the exported data as a list of dictionaries.
647
648        Parameters:
649        ----------
650        gcms : object
651            The low resolution GCMS object.
652        include_no_match : bool, optional
653            Whether to include no match data. Default is True.
654        no_match_inline : bool, optional
655            Whether to include no match data inline. Default is False.
656
657        Returns:
658        -------
659        list
660            The exported data as a list of dictionaries.
661        """
662
663        output_score_method = gcms.molecular_search_settings.output_score_method
664
665        dict_data_list = []
666
667        def add_match_dict_data():
668            derivatization = "{}:{}:{}".format(
669                compound_obj.classify,
670                compound_obj.derivativenum,
671                compound_obj.derivatization,
672            )
673            out_dict = {
674                "Sample name": gcms.sample_name,
675                "Peak Index": gcpeak_index,
676                "Retention Time": gc_peak.retention_time,
677                "Retention Time Ref": compound_obj.retention_time,
678                "Peak Height": gc_peak.tic,
679                "Peak Area": gc_peak.area,
680                "Retention index": gc_peak.ri,
681                "Retention index Ref": compound_obj.ri,
682                "Retention Index Score": compound_obj.ri_score,
683                "Spectral Similarity Score": compound_obj.spectral_similarity_score,
684                "Similarity Score": compound_obj.similarity_score,
685                "Compound Name": compound_obj.name,
686                "Chebi ID": compound_obj.metadata.chebi,
687                "Kegg Compound ID": compound_obj.metadata.kegg,
688                "Inchi": compound_obj.metadata.inchi,
689                "Inchi Key": compound_obj.metadata.inchikey,
690                "Smiles": compound_obj.metadata.smiles,
691                "Molecular Formula": compound_obj.formula,
692                "IUPAC Name": compound_obj.metadata.iupac_name,
693                "Traditional Name": compound_obj.metadata.traditional_name,
694                "Common Name": compound_obj.metadata.common_name,
695                "Derivatization": derivatization,
696            }
697
698            if self.gcms.molecular_search_settings.exploratory_mode:
699                out_dict.update(
700                    {
701                        "Weighted Cosine Correlation": compound_obj.spectral_similarity_scores.get(
702                            "weighted_cosine_correlation"
703                        ),
704                        "Cosine Correlation": compound_obj.spectral_similarity_scores.get(
705                            "cosine_correlation"
706                        ),
707                        "Stein Scott Similarity": compound_obj.spectral_similarity_scores.get(
708                            "stein_scott_similarity"
709                        ),
710                        "Pearson Correlation": compound_obj.spectral_similarity_scores.get(
711                            "pearson_correlation"
712                        ),
713                        "Spearman Correlation": compound_obj.spectral_similarity_scores.get(
714                            "spearman_correlation"
715                        ),
716                        "Kendall Tau Correlation": compound_obj.spectral_similarity_scores.get(
717                            "kendall_tau_correlation"
718                        ),
719                        "DFT Correlation": compound_obj.spectral_similarity_scores.get(
720                            "dft_correlation"
721                        ),
722                        "DWT Correlation": compound_obj.spectral_similarity_scores.get(
723                            "dwt_correlation"
724                        ),
725                        "Euclidean Distance": compound_obj.spectral_similarity_scores.get(
726                            "euclidean_distance"
727                        ),
728                        "Manhattan Distance": compound_obj.spectral_similarity_scores.get(
729                            "manhattan_distance"
730                        ),
731                        "Jaccard Distance": compound_obj.spectral_similarity_scores.get(
732                            "jaccard_distance"
733                        ),
734                    }
735                )
736                for method in methods_name:
737                    out_dict[methods_name.get(method)] = (
738                        compound_obj.spectral_similarity_scores.get(method)
739                    )
740
741            dict_data_list.append(out_dict)
742
743        def add_no_match_dict_data():
744            dict_data_list.append(
745                {
746                    "Sample name": gcms.sample_name,
747                    "Peak Index": gcpeak_index,
748                    "Retention Time": gc_peak.retention_time,
749                    "Peak Height": gc_peak.tic,
750                    "Peak Area": gc_peak.area,
751                    "Retention index": gc_peak.ri,
752                }
753            )
754
755        for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
756            # check if there is a compound candidate
757            if gc_peak:
758                if output_score_method == "highest_sim_score":
759                    compound_obj = gc_peak.highest_score_compound
760                    add_match_dict_data()
761
762                elif output_score_method == "highest_ss":
763                    compound_obj = gc_peak.highest_ss_compound
764                    add_match_dict_data()
765
766                else:
767                    for compound_obj in gc_peak:
768                        add_match_dict_data()  # add monoisotopic peak
769
770            else:
771                # include not_match
772                if include_no_match and no_match_inline:
773                    add_no_match_dict_data()
774
775        if include_no_match and not no_match_inline:
776            for gcpeak_index, gc_peak in enumerate(gcms.sorted_gcpeaks):
777                if not gc_peak:
778                    add_no_match_dict_data()
779
780        return dict_data_list

Get the exported data as a list of dictionaries.

Parameters:

gcms : object The low resolution GCMS object. include_no_match : bool, optional Whether to include no match data. Default is True. no_match_inline : bool, optional Whether to include no match data inline. Default is False.

Returns:

list The exported data as a list of dictionaries.

class HighResMassSpectraExport(corems.mass_spectrum.output.export.HighResMassSpecExport):
 783class HighResMassSpectraExport(HighResMassSpecExport):
 784    """A class to export high resolution mass spectra data.
 785
 786    This class provides methods to export high resolution mass spectra data to various formats
 787    such as Excel, CSV, HDF5, and Pandas DataFrame.
 788
 789    Parameters
 790    ----------
 791    out_file_path : str | Path
 792        The output file path.
 793    mass_spectra : object
 794        The high resolution mass spectra object.
 795    output_type : str, optional
 796        The output type. Default is 'excel'.
 797
 798    Attributes
 799    ----------
 800    output_file : Path
 801        The output file path without suffix
 802    dir_loc : Path
 803        The directory location for the output file,
 804        by default this will be the output_file + ".corems" and all output files will be
 805        written into this location
 806    mass_spectra : MassSpectraBase
 807        The high resolution mass spectra object.
 808    """
 809
 810    def __init__(self, out_file_path, mass_spectra, output_type="excel"):
 811        super().__init__(
 812            out_file_path=out_file_path, mass_spectrum=None, output_type=output_type
 813        )
 814
 815        self.dir_loc = Path(out_file_path + ".corems")
 816        self.dir_loc.mkdir(exist_ok=True)
 817        # Place the output file in the directory
 818        self.output_file = self.dir_loc / Path(out_file_path).name
 819        self._output_type = output_type  # 'excel', 'csv', 'pandas' or 'hdf5'
 820        self.mass_spectra = mass_spectra
 821        self.atoms_order_list = None
 822        self._init_columns()
 823
 824    def get_pandas_df(self):
 825        """Get the mass spectra as a list of Pandas DataFrames."""
 826
 827        list_df = []
 828
 829        for mass_spectrum in self.mass_spectra:
 830            columns = self.columns_label + self.get_all_used_atoms_in_order(
 831                mass_spectrum
 832            )
 833
 834            dict_data_list = self.get_list_dict_data(mass_spectrum)
 835
 836            df = DataFrame(dict_data_list, columns=columns)
 837
 838            scan_number = mass_spectrum.scan_number
 839
 840            df.name = str(self.output_file) + "_" + str(scan_number)
 841
 842            list_df.append(df)
 843
 844        return list_df
 845
 846    def to_pandas(self, write_metadata=True):
 847        """Export the data to a Pandas DataFrame and save it as a pickle file.
 848
 849        Parameters:
 850        ----------
 851        write_metadata : bool, optional
 852            Whether to write metadata to the output file. Default is True.
 853        """
 854
 855        for mass_spectrum in self.mass_spectra:
 856            columns = self.columns_label + self.get_all_used_atoms_in_order(
 857                mass_spectrum
 858            )
 859
 860            dict_data_list = self.get_list_dict_data(mass_spectrum)
 861
 862            df = DataFrame(dict_data_list, columns=columns)
 863
 864            scan_number = mass_spectrum.scan_number
 865
 866            out_filename = Path(
 867                "%s_scan%s%s" % (self.output_file, str(scan_number), ".pkl")
 868            )
 869
 870            df.to_pickle(self.dir_loc / out_filename)
 871
 872            if write_metadata:
 873                self.write_settings(
 874                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 875                )
 876
 877    def to_excel(self, write_metadata=True):
 878        """Export the data to an Excel file.
 879
 880        Parameters:
 881        ----------
 882        write_metadata : bool, optional
 883            Whether to write metadata to the output file. Default is True.
 884        """
 885        for mass_spectrum in self.mass_spectra:
 886            columns = self.columns_label + self.get_all_used_atoms_in_order(
 887                mass_spectrum
 888            )
 889
 890            dict_data_list = self.get_list_dict_data(mass_spectrum)
 891
 892            df = DataFrame(dict_data_list, columns=columns)
 893
 894            scan_number = mass_spectrum.scan_number
 895
 896            out_filename = Path(
 897                "%s_scan%s%s" % (self.output_file, str(scan_number), ".xlsx")
 898            )
 899
 900            df.to_excel(self.dir_loc / out_filename)
 901
 902            if write_metadata:
 903                self.write_settings(
 904                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 905                )
 906
 907    def to_csv(self, write_metadata=True):
 908        """Export the data to a CSV file.
 909
 910        Parameters:
 911        ----------
 912        write_metadata : bool, optional
 913            Whether to write metadata to the output file. Default is True.
 914        """
 915        import csv
 916
 917        for mass_spectrum in self.mass_spectra:
 918            columns = self.columns_label + self.get_all_used_atoms_in_order(
 919                mass_spectrum
 920            )
 921
 922            scan_number = mass_spectrum.scan_number
 923
 924            dict_data_list = self.get_list_dict_data(mass_spectrum)
 925
 926            out_filename = Path(
 927                "%s_scan%s%s" % (self.output_file, str(scan_number), ".csv")
 928            )
 929
 930            with open(self.dir_loc / out_filename, "w", newline="") as csvfile:
 931                writer = csv.DictWriter(csvfile, fieldnames=columns)
 932                writer.writeheader()
 933                for data in dict_data_list:
 934                    writer.writerow(data)
 935
 936            if write_metadata:
 937                self.write_settings(
 938                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
 939                )
 940
 941    def get_mass_spectra_attrs(self):
 942        """Get the mass spectra attributes as a JSON string.
 943
 944        Parameters:
 945        ----------
 946        mass_spectra : object
 947            The high resolution mass spectra object.
 948
 949        Returns:
 950        -------
 951        str
 952            The mass spectra attributes as a JSON string.
 953        """
 954        dict_ms_attrs = {}
 955        dict_ms_attrs["analyzer"] = self.mass_spectra.analyzer
 956        dict_ms_attrs["instrument_label"] = self.mass_spectra.instrument_label
 957        dict_ms_attrs["sample_name"] = self.mass_spectra.sample_name
 958
 959        return json.dumps(
 960            dict_ms_attrs, sort_keys=False, indent=4, separators=(",", ": ")
 961        )
 962
 963    def to_hdf(self, overwrite=False, export_raw=True):
 964        """Export the data to an HDF5 file.
 965
 966        Parameters
 967        ----------
 968        overwrite : bool, optional
 969            Whether to overwrite the output file. Default is False.
 970        export_raw : bool, optional
 971            Whether to export the raw mass spectra data. Default is True.
 972        """
 973        if overwrite:
 974            if self.output_file.with_suffix(".hdf5").exists():
 975                self.output_file.with_suffix(".hdf5").unlink()
 976
 977        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
 978            if not hdf_handle.attrs.get("date_utc"):
 979                # Set metadata for all mass spectra
 980                timenow = str(
 981                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
 982                )
 983                hdf_handle.attrs["date_utc"] = timenow
 984                hdf_handle.attrs["filename"] = self.mass_spectra.file_location.name
 985                hdf_handle.attrs["data_structure"] = "mass_spectra"
 986                hdf_handle.attrs["analyzer"] = self.mass_spectra.analyzer
 987                hdf_handle.attrs["instrument_label"] = (
 988                    self.mass_spectra.instrument_label
 989                )
 990                hdf_handle.attrs["sample_name"] = self.mass_spectra.sample_name
 991                hdf_handle.attrs["polarity"] = self.mass_spectra.polarity
 992                hdf_handle.attrs["parser_type"] = (
 993                    self.mass_spectra.spectra_parser_class.__name__
 994                )
 995                hdf_handle.attrs["original_file_location"] = (
 996                    self.mass_spectra.file_location._str
 997                )
 998                
 999                # Save creation time from original parser if available
1000                try:
1001                    if hasattr(self.mass_spectra, 'spectra_parser') and self.mass_spectra.spectra_parser is not None:
1002                        creation_time = self.mass_spectra.spectra_parser.get_creation_time()
1003                        if creation_time is not None:
1004                            hdf_handle.attrs["creation_time"] = creation_time.isoformat()
1005                except Exception:
1006                    pass  # If creation time cannot be retrieved, skip it
1007
1008            if "mass_spectra" not in hdf_handle:
1009                mass_spectra_group = hdf_handle.create_group("mass_spectra")
1010            else:
1011                mass_spectra_group = hdf_handle.get("mass_spectra")
1012
1013            for mass_spectrum in self.mass_spectra:
1014                group_key = str(int(mass_spectrum.scan_number))
1015
1016                self.add_mass_spectrum_to_hdf5(
1017                    hdf_handle, mass_spectrum, group_key, mass_spectra_group, export_raw
1018                )

A class to export high resolution mass spectra data.

This class provides methods to export high resolution mass spectra data to various formats such as Excel, CSV, HDF5, and Pandas DataFrame.

Parameters
  • out_file_path (str | Path): The output file path.
  • mass_spectra (object): The high resolution mass spectra object.
  • output_type (str, optional): The output type. Default is 'excel'.
Attributes
  • output_file (Path): The output file path without suffix
  • dir_loc (Path): The directory location for the output file, by default this will be the output_file + ".corems" and all output files will be written into this location
  • mass_spectra (MassSpectraBase): The high resolution mass spectra object.
HighResMassSpectraExport(out_file_path, mass_spectra, output_type='excel')
810    def __init__(self, out_file_path, mass_spectra, output_type="excel"):
811        super().__init__(
812            out_file_path=out_file_path, mass_spectrum=None, output_type=output_type
813        )
814
815        self.dir_loc = Path(out_file_path + ".corems")
816        self.dir_loc.mkdir(exist_ok=True)
817        # Place the output file in the directory
818        self.output_file = self.dir_loc / Path(out_file_path).name
819        self._output_type = output_type  # 'excel', 'csv', 'pandas' or 'hdf5'
820        self.mass_spectra = mass_spectra
821        self.atoms_order_list = None
822        self._init_columns()

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

dir_loc
output_file
mass_spectra
atoms_order_list
def get_pandas_df(self):
824    def get_pandas_df(self):
825        """Get the mass spectra as a list of Pandas DataFrames."""
826
827        list_df = []
828
829        for mass_spectrum in self.mass_spectra:
830            columns = self.columns_label + self.get_all_used_atoms_in_order(
831                mass_spectrum
832            )
833
834            dict_data_list = self.get_list_dict_data(mass_spectrum)
835
836            df = DataFrame(dict_data_list, columns=columns)
837
838            scan_number = mass_spectrum.scan_number
839
840            df.name = str(self.output_file) + "_" + str(scan_number)
841
842            list_df.append(df)
843
844        return list_df

Get the mass spectra as a list of Pandas DataFrames.

def to_pandas(self, write_metadata=True):
846    def to_pandas(self, write_metadata=True):
847        """Export the data to a Pandas DataFrame and save it as a pickle file.
848
849        Parameters:
850        ----------
851        write_metadata : bool, optional
852            Whether to write metadata to the output file. Default is True.
853        """
854
855        for mass_spectrum in self.mass_spectra:
856            columns = self.columns_label + self.get_all_used_atoms_in_order(
857                mass_spectrum
858            )
859
860            dict_data_list = self.get_list_dict_data(mass_spectrum)
861
862            df = DataFrame(dict_data_list, columns=columns)
863
864            scan_number = mass_spectrum.scan_number
865
866            out_filename = Path(
867                "%s_scan%s%s" % (self.output_file, str(scan_number), ".pkl")
868            )
869
870            df.to_pickle(self.dir_loc / out_filename)
871
872            if write_metadata:
873                self.write_settings(
874                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
875                )

Export the data to a Pandas DataFrame and save it as a pickle file.

Parameters:

write_metadata : bool, optional Whether to write metadata to the output file. Default is True.

def to_excel(self, write_metadata=True):
877    def to_excel(self, write_metadata=True):
878        """Export the data to an Excel file.
879
880        Parameters:
881        ----------
882        write_metadata : bool, optional
883            Whether to write metadata to the output file. Default is True.
884        """
885        for mass_spectrum in self.mass_spectra:
886            columns = self.columns_label + self.get_all_used_atoms_in_order(
887                mass_spectrum
888            )
889
890            dict_data_list = self.get_list_dict_data(mass_spectrum)
891
892            df = DataFrame(dict_data_list, columns=columns)
893
894            scan_number = mass_spectrum.scan_number
895
896            out_filename = Path(
897                "%s_scan%s%s" % (self.output_file, str(scan_number), ".xlsx")
898            )
899
900            df.to_excel(self.dir_loc / out_filename)
901
902            if write_metadata:
903                self.write_settings(
904                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
905                )

Export the data to an Excel file.

Parameters:

write_metadata : bool, optional Whether to write metadata to the output file. Default is True.

def to_csv(self, write_metadata=True):
907    def to_csv(self, write_metadata=True):
908        """Export the data to a CSV file.
909
910        Parameters:
911        ----------
912        write_metadata : bool, optional
913            Whether to write metadata to the output file. Default is True.
914        """
915        import csv
916
917        for mass_spectrum in self.mass_spectra:
918            columns = self.columns_label + self.get_all_used_atoms_in_order(
919                mass_spectrum
920            )
921
922            scan_number = mass_spectrum.scan_number
923
924            dict_data_list = self.get_list_dict_data(mass_spectrum)
925
926            out_filename = Path(
927                "%s_scan%s%s" % (self.output_file, str(scan_number), ".csv")
928            )
929
930            with open(self.dir_loc / out_filename, "w", newline="") as csvfile:
931                writer = csv.DictWriter(csvfile, fieldnames=columns)
932                writer.writeheader()
933                for data in dict_data_list:
934                    writer.writerow(data)
935
936            if write_metadata:
937                self.write_settings(
938                    self.dir_loc / out_filename.with_suffix(""), mass_spectrum
939                )

Export the data to a CSV file.

Parameters:

write_metadata : bool, optional Whether to write metadata to the output file. Default is True.

def get_mass_spectra_attrs(self):
941    def get_mass_spectra_attrs(self):
942        """Get the mass spectra attributes as a JSON string.
943
944        Parameters:
945        ----------
946        mass_spectra : object
947            The high resolution mass spectra object.
948
949        Returns:
950        -------
951        str
952            The mass spectra attributes as a JSON string.
953        """
954        dict_ms_attrs = {}
955        dict_ms_attrs["analyzer"] = self.mass_spectra.analyzer
956        dict_ms_attrs["instrument_label"] = self.mass_spectra.instrument_label
957        dict_ms_attrs["sample_name"] = self.mass_spectra.sample_name
958
959        return json.dumps(
960            dict_ms_attrs, sort_keys=False, indent=4, separators=(",", ": ")
961        )

Get the mass spectra attributes as a JSON string.

Parameters:

mass_spectra : object The high resolution mass spectra object.

Returns:

str The mass spectra attributes as a JSON string.

def to_hdf(self, overwrite=False, export_raw=True):
 963    def to_hdf(self, overwrite=False, export_raw=True):
 964        """Export the data to an HDF5 file.
 965
 966        Parameters
 967        ----------
 968        overwrite : bool, optional
 969            Whether to overwrite the output file. Default is False.
 970        export_raw : bool, optional
 971            Whether to export the raw mass spectra data. Default is True.
 972        """
 973        if overwrite:
 974            if self.output_file.with_suffix(".hdf5").exists():
 975                self.output_file.with_suffix(".hdf5").unlink()
 976
 977        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
 978            if not hdf_handle.attrs.get("date_utc"):
 979                # Set metadata for all mass spectra
 980                timenow = str(
 981                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
 982                )
 983                hdf_handle.attrs["date_utc"] = timenow
 984                hdf_handle.attrs["filename"] = self.mass_spectra.file_location.name
 985                hdf_handle.attrs["data_structure"] = "mass_spectra"
 986                hdf_handle.attrs["analyzer"] = self.mass_spectra.analyzer
 987                hdf_handle.attrs["instrument_label"] = (
 988                    self.mass_spectra.instrument_label
 989                )
 990                hdf_handle.attrs["sample_name"] = self.mass_spectra.sample_name
 991                hdf_handle.attrs["polarity"] = self.mass_spectra.polarity
 992                hdf_handle.attrs["parser_type"] = (
 993                    self.mass_spectra.spectra_parser_class.__name__
 994                )
 995                hdf_handle.attrs["original_file_location"] = (
 996                    self.mass_spectra.file_location._str
 997                )
 998                
 999                # Save creation time from original parser if available
1000                try:
1001                    if hasattr(self.mass_spectra, 'spectra_parser') and self.mass_spectra.spectra_parser is not None:
1002                        creation_time = self.mass_spectra.spectra_parser.get_creation_time()
1003                        if creation_time is not None:
1004                            hdf_handle.attrs["creation_time"] = creation_time.isoformat()
1005                except Exception:
1006                    pass  # If creation time cannot be retrieved, skip it
1007
1008            if "mass_spectra" not in hdf_handle:
1009                mass_spectra_group = hdf_handle.create_group("mass_spectra")
1010            else:
1011                mass_spectra_group = hdf_handle.get("mass_spectra")
1012
1013            for mass_spectrum in self.mass_spectra:
1014                group_key = str(int(mass_spectrum.scan_number))
1015
1016                self.add_mass_spectrum_to_hdf5(
1017                    hdf_handle, mass_spectrum, group_key, mass_spectra_group, export_raw
1018                )

Export the data to an HDF5 file.

Parameters
  • overwrite (bool, optional): Whether to overwrite the output file. Default is False.
  • export_raw (bool, optional): Whether to export the raw mass spectra data. Default is True.
class LCMSExport(HighResMassSpectraExport):
1021class LCMSExport(HighResMassSpectraExport):
1022    """A class to export high resolution LC-MS data.
1023
1024    This class provides methods to export high resolution LC-MS data to HDF5.
1025
1026    Parameters
1027    ----------
1028    out_file_path : str | Path
1029        The output file path, do not include the file extension.
1030    lcms_object : LCMSBase
1031        The high resolution lc-ms object.
1032    """
1033
1034    def __init__(self, out_file_path, mass_spectra):
1035        super().__init__(out_file_path, mass_spectra, output_type="hdf5")
1036
1037    @staticmethod
1038    def _save_mass_features_dict_to_hdf5(mass_features_dict, mass_features_group, overwrite=False):
1039        """Save a dictionary of mass features to an HDF5 group.
1040        
1041        This is a helper method that can be reused by different export classes.
1042        
1043        Parameters
1044        ----------
1045        mass_features_dict : dict
1046            Dictionary of mass features to save, keyed by mass feature ID.
1047        mass_features_group : h5py.Group
1048            The HDF5 group to save the mass features to.
1049        overwrite : bool, optional
1050            Whether to overwrite existing mass features. Default is False.
1051        """
1052
1053        # Create group for each mass feature, with key as the mass feature id
1054        for k, v in mass_features_dict.items():
1055                if str(k) not in mass_features_group or overwrite:
1056                    if str(k) in mass_features_group and overwrite:
1057                        del mass_features_group[str(k)]
1058                    mass_features_group.create_group(str(k))
1059                    # Loop through each of the mass feature attributes and add them as attributes (if single value) or datasets (if array)
1060                    for k2, v2 in v.__dict__.items():
1061                        if v2 is not None:
1062                            # Check if the attribute is an integer or float and set as an attribute in the mass feature group
1063                            if k2 not in [
1064                                "chromatogram_parent",
1065                                "ms2_mass_spectra",
1066                                "mass_spectrum",
1067                                "_eic_data",
1068                                "ms2_similarity_results",
1069                            ]:
1070                                if k2 == "ms2_scan_numbers":
1071                                    array = np.array(v2)
1072                                    # Convert int64 to int32
1073                                    if array.dtype == np.int64:
1074                                        array = array.astype(np.int32)
1075                                    mass_features_group[str(k)].create_dataset(
1076                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1077                                    )
1078                                elif k2 == "_half_height_width":
1079                                    array = np.array(v2)
1080                                    # Convert float64 to float32
1081                                    if array.dtype == np.float64:
1082                                        array = array.astype(np.float32)
1083                                    mass_features_group[str(k)].create_dataset(
1084                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1085                                    )
1086                                elif k2 == "_ms_deconvoluted_idx":
1087                                    array = np.array(v2)
1088                                    # Convert int64 to int32
1089                                    if array.dtype == np.int64:
1090                                        array = array.astype(np.int32)
1091                                    mass_features_group[str(k)].create_dataset(
1092                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1093                                    )
1094                                elif k2 == "associated_mass_features_deconvoluted":
1095                                    array = np.array(v2)
1096                                    # Convert int64 to int32
1097                                    if array.dtype == np.int64:
1098                                        array = array.astype(np.int32)
1099                                    mass_features_group[str(k)].create_dataset(
1100                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1101                                    )
1102                                elif k2 == "_noise_score":
1103                                    array = np.array(v2)
1104                                    # Convert float64 to float32
1105                                    if array.dtype == np.float64:
1106                                        array = array.astype(np.float32)
1107                                    mass_features_group[str(k)].create_dataset(
1108                                        str(k2), data=array, compression="gzip", compression_opts=9, chunks=True
1109                                    )
1110                                elif (
1111                                    isinstance(v2, int)
1112                                    or isinstance(v2, float)
1113                                    or isinstance(v2, str)
1114                                    or isinstance(v2, np.integer)
1115                                    or isinstance(v2, np.float32)
1116                                    or isinstance(v2, np.float64)
1117                                    or isinstance(v2, np.bool_)
1118                                ):
1119                                    # Convert numpy types to smaller precision for storage
1120                                    if isinstance(v2, np.int64):
1121                                        v2 = np.int32(v2)
1122                                    elif isinstance(v2, np.float64):
1123                                        v2 = np.float32(v2)
1124                                    mass_features_group[str(k)].attrs[str(k2)] = v2
1125    
1126    @staticmethod
1127    def _save_eics_dict_to_hdf5(eics_dict, eics_group, overwrite=False):
1128        """Save a dictionary of EICs to an HDF5 group.
1129        
1130        This is a static helper method that can be reused by different export classes
1131        to save EIC data in a consistent format.
1132        
1133        Parameters
1134        ----------
1135        eics_dict : dict
1136            Dictionary of EIC_Data objects, keyed by m/z value.
1137        eics_group : h5py.Group
1138            The HDF5 group to save the EICs to.
1139        overwrite : bool, optional
1140            Whether to overwrite existing EICs. Default is False.
1141        """
1142        for mz, eic_data in eics_dict.items():
1143            mz_str = str(mz)
1144            if mz_str not in eics_group or overwrite:
1145                if mz_str in eics_group and overwrite:
1146                    del eics_group[mz_str]
1147                eic_grp = eics_group.create_group(mz_str)
1148                eic_grp.attrs["mz"] = mz
1149                
1150                # Save all EIC_Data attributes as datasets
1151                for attr_name, attr_value in eic_data.__dict__.items():
1152                    if attr_value is not None:
1153                        array = np.array(attr_value)
1154                        # Apply data type optimization and compression
1155                        if array.dtype == np.int64:
1156                            array = array.astype(np.int32)
1157                        elif array.dtype == np.float64:
1158                            array = array.astype(np.float32)
1159                        elif array.dtype.str[0:2] == "<U":
1160                            # Convert Unicode strings to UTF-8 encoded strings
1161                            string_data = [str(item) for item in array]
1162                            string_dtype = h5py.string_dtype(encoding='utf-8')
1163                            eic_grp.create_dataset(str(attr_name), data=string_data, dtype=string_dtype, compression="gzip", compression_opts=9, chunks=True)
1164                            continue
1165                        eic_grp.create_dataset(str(attr_name), data=array, compression="gzip", compression_opts=9, chunks=True)
1166    
1167    def _save_mass_features_to_hdf5(self, hdf_handle, group_name = "mass_features", overwrite=False):
1168        """Save the mass features to the HDF5 file.
1169
1170        Parameters
1171        ----------
1172        hdf_handle : h5py.File
1173            The HDF5 file handle.
1174        group_name : str, optional
1175            The name of the group to save the mass features to. Default is 'mass_features'.
1176        overwrite : bool, optional
1177            Whether to overwrite the group if it exists. Default is False.
1178        """
1179        # Determine which mass features to save based on group_name
1180        if group_name == "induced_mass_features":
1181            if len(self.mass_spectra.induced_mass_features) == 0:
1182                return  # No induced mass features to save
1183            mass_features_dict = self.mass_spectra.induced_mass_features
1184        else:
1185            if len(self.mass_spectra.mass_features) == 0:
1186                return  # No mass features to save
1187            mass_features_dict = self.mass_spectra.mass_features
1188
1189        # Add LCMS mass features to hdf5 file
1190        if group_name not in hdf_handle:
1191            mass_features_group = hdf_handle.create_group(group_name)
1192        else:
1193            mass_features_group = hdf_handle.get(group_name)
1194        
1195        # Use the static helper method to save the mass features
1196        self._save_mass_features_dict_to_hdf5(mass_features_dict, mass_features_group, overwrite)
1197
1198    def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml"):
1199        """Export the data to an HDF5.
1200
1201        Parameters
1202        ----------
1203        overwrite : bool, optional
1204            Whether to overwrite the output file. Default is False.
1205        save_parameters : bool, optional
1206            Whether to save the parameters as a separate json or toml file. Default is True.
1207        parameter_format : str, optional
1208            The format to save the parameters in. Default is 'toml'.
1209
1210        Raises
1211        ------
1212        ValueError
1213            If parameter_format is not 'json' or 'toml'.
1214        """
1215        export_profile_spectra = (
1216            self.mass_spectra.parameters.lc_ms.export_profile_spectra
1217        )
1218
1219        # Write the mass spectra data to the hdf5 file
1220        super().to_hdf(overwrite=overwrite, export_raw=export_profile_spectra)
1221
1222        # Write scan info, ms_unprocessed, mass features, eics, and ms2_search results to the hdf5 file
1223        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
1224            # Add scan_info to hdf5 file
1225            if "scan_info" not in hdf_handle or overwrite:
1226                if "scan_info" in hdf_handle and overwrite:
1227                    del hdf_handle["scan_info"]
1228                scan_info_group = hdf_handle.create_group("scan_info")
1229                for k, v in self.mass_spectra._scan_info.items():
1230                    array = np.array(list(v.values()))
1231                    if array.dtype.str[0:2] == "<U":
1232                        array = array.astype("S")
1233                    scan_info_group.create_dataset(k, data=array)
1234
1235            # Add ms_unprocessed to hdf5 file
1236            export_unprocessed_ms1 = (
1237                self.mass_spectra.parameters.lc_ms.export_unprocessed_ms1
1238            )
1239            if self.mass_spectra._ms_unprocessed and export_unprocessed_ms1:
1240                if "ms_unprocessed" not in hdf_handle or overwrite:
1241                    if "ms_unprocessed" in hdf_handle and overwrite:
1242                        del hdf_handle["ms_unprocessed"]
1243                    ms_unprocessed_group = hdf_handle.create_group("ms_unprocessed")
1244                else:
1245                    ms_unprocessed_group = hdf_handle.get("ms_unprocessed")
1246                for k, v in self.mass_spectra._ms_unprocessed.items():
1247                    if str(k) not in ms_unprocessed_group or overwrite:
1248                        if str(k) in ms_unprocessed_group and overwrite:
1249                            del ms_unprocessed_group[str(k)]
1250                        array = np.array(v)
1251                        ms_unprocessed_group.create_dataset(str(k), data=array)
1252
1253            # Add LCMS mass features to hdf5 file
1254            self._save_mass_features_to_hdf5(hdf_handle, group_name="mass_features", overwrite=overwrite)
1255            self._save_mass_features_to_hdf5(hdf_handle, group_name="induced_mass_features", overwrite=overwrite)
1256
1257            # Add EIC data to hdf5 file
1258            export_eics = self.mass_spectra.parameters.lc_ms.export_eics
1259            if len(self.mass_spectra.eics) > 0 and export_eics:
1260                if "eics" not in hdf_handle or overwrite:
1261                    if "eics" in hdf_handle and overwrite:
1262                        del hdf_handle["eics"]
1263                    eic_group = hdf_handle.create_group("eics")
1264                else:
1265                    eic_group = hdf_handle.get("eics")
1266
1267                # Use the static helper method to save the EICs
1268                self._save_eics_dict_to_hdf5(self.mass_spectra.eics, eic_group, overwrite)
1269
1270            # Add ms2_search results to hdf5 file (parameterized)
1271            if len(self.mass_spectra.spectral_search_results) > 0:
1272                if "spectral_search_results" not in hdf_handle or overwrite:
1273                    if "spectral_search_results" in hdf_handle and overwrite:
1274                        del hdf_handle["spectral_search_results"]
1275                    spectral_search_results = hdf_handle.create_group(
1276                        "spectral_search_results"
1277                    )
1278                else:
1279                    spectral_search_results = hdf_handle.get("spectral_search_results")
1280                # Create group for each search result by ms2_scan / precursor_mz
1281                for k, v in self.mass_spectra.spectral_search_results.items():
1282                    #TODO KRH: Fix to handle if export_only_relevant and k not in relevant_scan_numbers: continue!
1283                    if str(k) not in spectral_search_results or overwrite:
1284                        if str(k) in spectral_search_results and overwrite:
1285                            del spectral_search_results[str(k)]
1286                        spectral_search_results.create_group(str(k))
1287                        for k2, v2 in v.items():
1288                            spectral_search_results[str(k)].create_group(str(k2))
1289                            spectral_search_results[str(k)][str(k2)].attrs[
1290                                "precursor_mz"
1291                            ] = v2.precursor_mz
1292                            spectral_search_results[str(k)][str(k2)].attrs[
1293                                "query_spectrum_id"
1294                            ] = v2.query_spectrum_id
1295                            # Loop through each of the attributes and add them as datasets (if array)
1296                            for k3, v3 in v2.__dict__.items():
1297                                if v3 is not None and k3 not in [
1298                                    "query_spectrum",
1299                                    "precursor_mz",
1300                                    "query_spectrum_id",
1301                                ]:
1302                                    if k3 == "query_frag_types" or k3 == "ref_frag_types":
1303                                        v3 = [", ".join(x) for x in v3]
1304                                    if all(v3 is not None for v3 in v3):
1305                                        array = np.array(v3)
1306                                    if array.dtype.str[0:2] == "<U":
1307                                        array = array.astype("S")
1308                                    spectral_search_results[str(k)][str(k2)].create_dataset(
1309                                        str(k3), data=array
1310                                    )
1311
1312            # Save parameters as separate json
1313            if save_parameters:
1314                # Check if parameter_format is valid
1315                if parameter_format not in ["json", "toml"]:
1316                    raise ValueError("parameter_format must be 'json' or 'toml'")
1317
1318                if parameter_format == "json":
1319                    dump_lcms_settings_json(
1320                        filename=self.output_file.with_suffix(".json"),
1321                        lcms_obj=self.mass_spectra,
1322                    )
1323                elif parameter_format == "toml":
1324                    dump_lcms_settings_toml(
1325                        filename=self.output_file.with_suffix(".toml"),
1326                        lcms_obj=self.mass_spectra,
1327                    )

A class to export high resolution LC-MS data.

This class provides methods to export high resolution LC-MS data to HDF5.

Parameters
  • out_file_path (str | Path): The output file path, do not include the file extension.
  • lcms_object (LCMSBase): The high resolution lc-ms object.
LCMSExport(out_file_path, mass_spectra)
1034    def __init__(self, out_file_path, mass_spectra):
1035        super().__init__(out_file_path, mass_spectra, output_type="hdf5")

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

def to_hdf(self, overwrite=False, save_parameters=True, parameter_format='toml'):
1198    def to_hdf(self, overwrite=False, save_parameters=True, parameter_format="toml"):
1199        """Export the data to an HDF5.
1200
1201        Parameters
1202        ----------
1203        overwrite : bool, optional
1204            Whether to overwrite the output file. Default is False.
1205        save_parameters : bool, optional
1206            Whether to save the parameters as a separate json or toml file. Default is True.
1207        parameter_format : str, optional
1208            The format to save the parameters in. Default is 'toml'.
1209
1210        Raises
1211        ------
1212        ValueError
1213            If parameter_format is not 'json' or 'toml'.
1214        """
1215        export_profile_spectra = (
1216            self.mass_spectra.parameters.lc_ms.export_profile_spectra
1217        )
1218
1219        # Write the mass spectra data to the hdf5 file
1220        super().to_hdf(overwrite=overwrite, export_raw=export_profile_spectra)
1221
1222        # Write scan info, ms_unprocessed, mass features, eics, and ms2_search results to the hdf5 file
1223        with h5py.File(self.output_file.with_suffix(".hdf5"), "a") as hdf_handle:
1224            # Add scan_info to hdf5 file
1225            if "scan_info" not in hdf_handle or overwrite:
1226                if "scan_info" in hdf_handle and overwrite:
1227                    del hdf_handle["scan_info"]
1228                scan_info_group = hdf_handle.create_group("scan_info")
1229                for k, v in self.mass_spectra._scan_info.items():
1230                    array = np.array(list(v.values()))
1231                    if array.dtype.str[0:2] == "<U":
1232                        array = array.astype("S")
1233                    scan_info_group.create_dataset(k, data=array)
1234
1235            # Add ms_unprocessed to hdf5 file
1236            export_unprocessed_ms1 = (
1237                self.mass_spectra.parameters.lc_ms.export_unprocessed_ms1
1238            )
1239            if self.mass_spectra._ms_unprocessed and export_unprocessed_ms1:
1240                if "ms_unprocessed" not in hdf_handle or overwrite:
1241                    if "ms_unprocessed" in hdf_handle and overwrite:
1242                        del hdf_handle["ms_unprocessed"]
1243                    ms_unprocessed_group = hdf_handle.create_group("ms_unprocessed")
1244                else:
1245                    ms_unprocessed_group = hdf_handle.get("ms_unprocessed")
1246                for k, v in self.mass_spectra._ms_unprocessed.items():
1247                    if str(k) not in ms_unprocessed_group or overwrite:
1248                        if str(k) in ms_unprocessed_group and overwrite:
1249                            del ms_unprocessed_group[str(k)]
1250                        array = np.array(v)
1251                        ms_unprocessed_group.create_dataset(str(k), data=array)
1252
1253            # Add LCMS mass features to hdf5 file
1254            self._save_mass_features_to_hdf5(hdf_handle, group_name="mass_features", overwrite=overwrite)
1255            self._save_mass_features_to_hdf5(hdf_handle, group_name="induced_mass_features", overwrite=overwrite)
1256
1257            # Add EIC data to hdf5 file
1258            export_eics = self.mass_spectra.parameters.lc_ms.export_eics
1259            if len(self.mass_spectra.eics) > 0 and export_eics:
1260                if "eics" not in hdf_handle or overwrite:
1261                    if "eics" in hdf_handle and overwrite:
1262                        del hdf_handle["eics"]
1263                    eic_group = hdf_handle.create_group("eics")
1264                else:
1265                    eic_group = hdf_handle.get("eics")
1266
1267                # Use the static helper method to save the EICs
1268                self._save_eics_dict_to_hdf5(self.mass_spectra.eics, eic_group, overwrite)
1269
1270            # Add ms2_search results to hdf5 file (parameterized)
1271            if len(self.mass_spectra.spectral_search_results) > 0:
1272                if "spectral_search_results" not in hdf_handle or overwrite:
1273                    if "spectral_search_results" in hdf_handle and overwrite:
1274                        del hdf_handle["spectral_search_results"]
1275                    spectral_search_results = hdf_handle.create_group(
1276                        "spectral_search_results"
1277                    )
1278                else:
1279                    spectral_search_results = hdf_handle.get("spectral_search_results")
1280                # Create group for each search result by ms2_scan / precursor_mz
1281                for k, v in self.mass_spectra.spectral_search_results.items():
1282                    #TODO KRH: Fix to handle if export_only_relevant and k not in relevant_scan_numbers: continue!
1283                    if str(k) not in spectral_search_results or overwrite:
1284                        if str(k) in spectral_search_results and overwrite:
1285                            del spectral_search_results[str(k)]
1286                        spectral_search_results.create_group(str(k))
1287                        for k2, v2 in v.items():
1288                            spectral_search_results[str(k)].create_group(str(k2))
1289                            spectral_search_results[str(k)][str(k2)].attrs[
1290                                "precursor_mz"
1291                            ] = v2.precursor_mz
1292                            spectral_search_results[str(k)][str(k2)].attrs[
1293                                "query_spectrum_id"
1294                            ] = v2.query_spectrum_id
1295                            # Loop through each of the attributes and add them as datasets (if array)
1296                            for k3, v3 in v2.__dict__.items():
1297                                if v3 is not None and k3 not in [
1298                                    "query_spectrum",
1299                                    "precursor_mz",
1300                                    "query_spectrum_id",
1301                                ]:
1302                                    if k3 == "query_frag_types" or k3 == "ref_frag_types":
1303                                        v3 = [", ".join(x) for x in v3]
1304                                    if all(v3 is not None for v3 in v3):
1305                                        array = np.array(v3)
1306                                    if array.dtype.str[0:2] == "<U":
1307                                        array = array.astype("S")
1308                                    spectral_search_results[str(k)][str(k2)].create_dataset(
1309                                        str(k3), data=array
1310                                    )
1311
1312            # Save parameters as separate json
1313            if save_parameters:
1314                # Check if parameter_format is valid
1315                if parameter_format not in ["json", "toml"]:
1316                    raise ValueError("parameter_format must be 'json' or 'toml'")
1317
1318                if parameter_format == "json":
1319                    dump_lcms_settings_json(
1320                        filename=self.output_file.with_suffix(".json"),
1321                        lcms_obj=self.mass_spectra,
1322                    )
1323                elif parameter_format == "toml":
1324                    dump_lcms_settings_toml(
1325                        filename=self.output_file.with_suffix(".toml"),
1326                        lcms_obj=self.mass_spectra,
1327                    )

Export the data to an HDF5.

Parameters
  • overwrite (bool, optional): Whether to overwrite the output file. Default is False.
  • save_parameters (bool, optional): Whether to save the parameters as a separate json or toml file. Default is True.
  • parameter_format (str, optional): The format to save the parameters in. Default is 'toml'.
Raises
  • ValueError: If parameter_format is not 'json' or 'toml'.
class LCMSMetabolomicsExport(LCMSExport):
1329class LCMSMetabolomicsExport(LCMSExport):
1330    """A class to export LCMS metabolite data.
1331
1332    This class provides methods to export LCMS metabolite data to various formats and summarize the metabolite report.
1333
1334    Parameters
1335    ----------
1336    out_file_path : str | Path
1337        The output file path, do not include the file extension.
1338    mass_spectra : object
1339        The high resolution mass spectra object.
1340    """
1341
1342    def __init__(self, out_file_path, mass_spectra):
1343        super().__init__(out_file_path, mass_spectra)
1344        self.ion_type_dict = ion_type_dict
1345    
1346    @staticmethod
1347    def get_ion_formula(neutral_formula, ion_type):
1348        """From a neutral formula and an ion type, return the formula of the ion.
1349
1350        Notes
1351        -----
1352        This is a static method.
1353        If the neutral_formula is not a string, this method will return None.
1354
1355        Parameters
1356        ----------
1357        neutral_formula : str
1358            The neutral formula, this should be a string form from the MolecularFormula class
1359            (e.g. 'C2 H4 O2', isotopes OK), or simple string (e.g. 'C2H4O2', no isotope handling in this case).
1360            In the case of a simple string, the atoms are parsed based on the presence of capital letters,
1361            e.g. MgCl2 is parsed as 'Mg Cl2.
1362        ion_type : str
1363            The ion type, e.g. 'protonated', '[M+H]+', '[M+Na]+', etc.
1364            See the self.ion_type_dict for the available ion types.
1365
1366        Returns
1367        -------
1368        str
1369            The formula of the ion as a string (like 'C2 H4 O2'); or None if the neutral_formula is not a string.
1370        """
1371        # If neutral_formula is not a string, return None
1372        if not isinstance(neutral_formula, str):
1373            return None
1374
1375        # Check if there are spaces in the formula (these are outputs of the MolecularFormula class and do not need to be processed before being passed to the class)
1376        if re.search(r"\s", neutral_formula):
1377            neutral_formula = MolecularFormula(neutral_formula, ion_charge=0)
1378        else:
1379            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
1380            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
1381            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
1382            neutral_formula = MolecularFormula(
1383                dict(
1384                    zip(
1385                        [x[0] for x in elements],
1386                        [int(x[0]) if x else 1 for x in counts],
1387                    )
1388                ),
1389                ion_charge=0,
1390            )
1391        neutral_formula_dict = neutral_formula.to_dict().copy()
1392
1393        adduct_add_dict = ion_type_dict[ion_type][0]
1394        for key in adduct_add_dict:
1395            if key in neutral_formula_dict.keys():
1396                neutral_formula_dict[key] += adduct_add_dict[key]
1397            else:
1398                neutral_formula_dict[key] = adduct_add_dict[key]
1399
1400        adduct_subtract = ion_type_dict[ion_type][1]
1401        for key in adduct_subtract:
1402            neutral_formula_dict[key] -= adduct_subtract[key]
1403
1404        return MolecularFormula(neutral_formula_dict, ion_charge=0).string
1405
1406    @staticmethod
1407    def get_isotope_type(ion_formula):
1408        """From an ion formula, return the 13C isotope type of the ion.
1409
1410        Notes
1411        -----
1412        This is a static method.
1413        If the ion_formula is not a string, this method will return None.
1414        This is currently only functional for 13C isotopes.
1415
1416        Parameters
1417        ----------
1418        ion_formula : str
1419            The formula of the ion, expected to be a string like 'C2 H4 O2'.
1420
1421        Returns
1422        -------
1423        str
1424            The isotope type of the ion, e.g. '13C1', '13C2', etc; or None if the ion_formula does not contain a 13C isotope.
1425
1426        Raises
1427        ------
1428        ValueError
1429            If the ion_formula is not a string.
1430        """
1431        if not isinstance(ion_formula, str):
1432            return None
1433
1434        if re.search(r"\s", ion_formula):
1435            ion_formula = MolecularFormula(ion_formula, ion_charge=0)
1436        else:
1437            raise ValueError('ion_formula should be a string like "C2 H4 O2"')
1438        ion_formula_dict = ion_formula.to_dict().copy()
1439
1440        try:
1441            iso_class = "13C" + str(ion_formula_dict.pop("13C"))
1442        except KeyError:
1443            iso_class = None
1444
1445        return iso_class
1446    
1447    def report_to_csv(self, molecular_metadata=None):
1448        """Create a report of the mass features and their annotations and save it as a CSV file.
1449
1450        Parameters
1451        ----------
1452        molecular_metadata : dict, optional
1453            The molecular metadata. Default is None.
1454        """
1455        report = self.to_report(molecular_metadata=molecular_metadata)
1456        out_file = self.output_file.with_suffix(".csv")
1457        report.to_csv(out_file, index=False)
1458    
1459    def clean_ms1_report(self, ms1_summary_full):
1460        """Clean the MS1 report.
1461
1462        Parameters
1463        ----------
1464        ms1_summary_full : DataFrame
1465            The full MS1 summary DataFrame.
1466
1467        Returns
1468        -------
1469        DataFrame
1470            The cleaned MS1 summary DataFrame.
1471        """
1472        ms1_summary_full = ms1_summary_full.reset_index()
1473        cols_to_keep = [
1474            "mf_id",
1475            "Molecular Formula",
1476            "Ion Type",
1477            "Calculated m/z",
1478            "m/z Error (ppm)",
1479            "m/z Error Score",
1480            "Is Isotopologue",
1481            "Isotopologue Similarity",
1482            "Confidence Score",
1483        ]
1484        ms1_summary = ms1_summary_full[cols_to_keep].copy()
1485        ms1_summary["ion_formula"] = [
1486            self.get_ion_formula(f, a)
1487            for f, a in zip(ms1_summary["Molecular Formula"], ms1_summary["Ion Type"])
1488        ]
1489        ms1_summary["isotopologue_type"] = [
1490            self.get_isotope_type(f) for f in ms1_summary["ion_formula"].tolist()
1491        ]
1492
1493        # Reorder columns
1494        ms1_summary = ms1_summary[
1495            [
1496                "mf_id",
1497                "ion_formula",
1498                "isotopologue_type",
1499                "Calculated m/z",
1500                "m/z Error (ppm)",
1501                "m/z Error Score",
1502                "Isotopologue Similarity",
1503                "Confidence Score",
1504            ]
1505        ]
1506
1507        # Set the index to mf_id
1508        ms1_summary = ms1_summary.set_index("mf_id")
1509
1510        return ms1_summary
1511    
1512    def summarize_ms2_report(self, ms2_annot_report):
1513        """
1514        Summarize the MS2 report.
1515
1516        Parameters
1517        ----------
1518        ms2_annot_report : DataFrame
1519            The MS2 annotation DataFrame with all annotations, output of mass_features_ms2_annot_to_df.
1520        
1521        Returns
1522        -------
1523        """
1524    
1525    def summarize_metabolomics_report(self, ms2_annot_report):
1526        """Summarize the MS2 hits for a metabolomics report
1527        
1528        Parameters
1529        ----------
1530        ms2_annot : DataFrame
1531            The MS2 annotation DataFrame with all annotations.
1532
1533        Returns
1534        -------
1535        DataFrame
1536            The summarized metabolomics report.
1537        """
1538        columns_to_drop = [
1539            "precursor_mz",
1540            "precursor_mz_error_ppm",
1541            "cas",
1542            "data_id",
1543            "iupac_name",
1544            "traditional_name",
1545            "common_name",
1546            "casno",
1547        ]
1548        ms2_annot = ms2_annot_report.drop(
1549            columns=[col for col in columns_to_drop if col in ms2_annot_report.columns]
1550        )
1551        
1552        # Prepare information about the search results, pulling out the best hit for the single report
1553        # Group by mf_id,ref_mol_id grab row with highest entropy similarity
1554        ms2_annot = ms2_annot.reset_index()
1555        # Add column called "n_spectra_contributing" that is the number of unique values in query_spectrum_id per mf_id,ref_mol_id
1556        ms2_annot["n_spectra_contributing"] = (
1557            ms2_annot.groupby(["mf_id", "ref_mol_id"])["query_spectrum_id"]
1558            .transform("nunique")
1559        )
1560        # Sort by entropy similarity
1561        ms2_annot = ms2_annot.sort_values(
1562            by=["mf_id", "ref_mol_id", "entropy_similarity"], ascending=[True, True, False]
1563        )
1564        best_entropy = ms2_annot.drop_duplicates(
1565            subset=["mf_id", "ref_mol_id"], keep="first"
1566        )
1567
1568        return best_entropy
1569
1570    def clean_ms2_report(self, metabolite_summary):
1571        """Clean the MS2 report.
1572
1573        Parameters
1574        ----------
1575        metabolite_summary : DataFrame
1576            The full metabolomics summary DataFrame.
1577
1578        Returns
1579        -------
1580        DataFrame
1581            The cleaned metabolomics summary DataFrame.
1582        """
1583        metabolite_summary = metabolite_summary.reset_index()
1584        metabolite_summary["ion_formula"] = [
1585            self.get_ion_formula(f, a)
1586            for f, a in zip(metabolite_summary["formula"], metabolite_summary["ref_ion_type"])
1587        ]
1588
1589        col_order = [
1590            "mf_id",
1591            "ion_formula",
1592            "ref_ion_type",
1593            "formula",
1594            "inchikey",
1595            "name",
1596            "inchi",
1597            "chebi",
1598            "smiles",
1599            "kegg",
1600            "cas",
1601            "database_name",
1602            "ref_ms_id",
1603            "entropy_similarity",
1604            "ref_mz_in_query_fract",
1605            "n_spectra_contributing",
1606        ]
1607
1608        # Reorder columns
1609        metabolite_summary = metabolite_summary[
1610            [col for col in col_order if col in metabolite_summary.columns]
1611        ]
1612
1613        # Convert chebi (if present) to int:
1614        if "chebi" in metabolite_summary.columns:
1615            metabolite_summary["chebi"] = metabolite_summary["chebi"].astype(
1616                "Int64", errors="ignore"
1617            )
1618
1619        # Set the index to mf_id
1620        metabolite_summary = metabolite_summary.set_index("mf_id")
1621
1622        return metabolite_summary
1623    
1624    def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
1625        """Combine the mass feature report with the MS1 and MS2 reports.
1626
1627        Parameters
1628        ----------
1629        mf_report : DataFrame
1630            The mass feature report DataFrame.
1631        ms1_annot_report : DataFrame
1632            The MS1 annotation report DataFrame.
1633        ms2_annot_report : DataFrame
1634            The MS2 annotation report DataFrame.
1635        """
1636        # If there is an ms1_annot_report, merge it with the mf_report
1637        if ms1_annot_report is not None and not ms1_annot_report.empty:
1638            # MS1 has been run and has molecular formula information
1639            mf_report = pd.merge(
1640                mf_report,
1641                ms1_annot_report,
1642                how="left",
1643                on=["mf_id", "isotopologue_type"],
1644            )
1645        if ms2_annot_report is not None:
1646            # If both reports contain 'ion_formula', prefer a merge that respects it.
1647            # Otherwise fall back to merging on 'mf_id' only to remain robust when
1648            # MS1 formula assignment wasn't performed or MS2 summary lacks the field.
1649            if "ion_formula" in mf_report.columns and "ion_formula" in ms2_annot_report.columns:
1650                # pull out the records without ion_formula and merge on mf_id only
1651                mf_no_ion_formula = mf_report[mf_report["ion_formula"].isna()]
1652                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
1653                mf_no_ion_formula = pd.merge(
1654                    mf_no_ion_formula, ms2_annot_report, how="left", on=["mf_id"]
1655                )
1656
1657                # pull out the records with ion_formula and merge on mf_id + ion_formula
1658                mf_with_ion_formula = mf_report[~mf_report["ion_formula"].isna()]
1659                mf_with_ion_formula = pd.merge(
1660                    mf_with_ion_formula,
1661                    ms2_annot_report,
1662                    how="left",
1663                    on=["mf_id", "ion_formula"],
1664                )
1665
1666                # put back together
1667                mf_report = pd.concat([mf_no_ion_formula, mf_with_ion_formula])
1668            else:
1669                # Fall back to merging on mf_id only (robust when ion_formula missing)
1670                mf_report = pd.merge(
1671                    mf_report, ms2_annot_report, how="left", on=["mf_id"]
1672                )
1673
1674        # Rename colums
1675        rename_dict = {
1676            "mf_id": "Mass Feature ID",
1677            "scan_time": "Retention Time (min)",
1678            "mz": "m/z",
1679            "apex_scan": "Apex Scan Number",
1680            "intensity": "Intensity",
1681            "persistence": "Persistence",
1682            "area": "Area",
1683            "half_height_width": "Half Height Width (min)",
1684            "tailing_factor": "Tailing Factor",
1685            "dispersity_index": "Dispersity Index",
1686            "ms2_spectrum": "MS2 Spectrum",
1687            "monoisotopic_mf_id": "Monoisotopic Mass Feature ID",
1688            "isotopologue_type": "Isotopologue Type",
1689            "mass_spectrum_deconvoluted_parent": "Is Largest Ion after Deconvolution",
1690            "associated_mass_features": "Associated Mass Features after Deconvolution",
1691            "ion_formula": "Ion Formula",
1692            "formula": "Molecular Formula",
1693            "ref_ion_type": "Ion Type",
1694            "annot_level": "Lipid Annotation Level",
1695            "lipid_molecular_species_id": "Lipid Molecular Species",
1696            "lipid_summed_name": "Lipid Species",
1697            "lipid_subclass": "Lipid Subclass",
1698            "lipid_class": "Lipid Class",
1699            "lipid_category": "Lipid Category",
1700            "entropy_similarity": "Entropy Similarity",
1701            "ref_mz_in_query_fract": "Library mzs in Query (fraction)",
1702            "n_spectra_contributing": "Spectra with Annotation (n)",
1703        }
1704        mf_report = mf_report.rename(columns=rename_dict)
1705        mf_report["Sample Name"] = self.mass_spectra.sample_name
1706        mf_report["Polarity"] = self.mass_spectra.polarity
1707        mf_report = mf_report[
1708            ["Mass Feature ID", "Sample Name", "Polarity"]
1709            + [
1710                col
1711                for col in mf_report.columns
1712                if col not in ["Mass Feature ID", "Sample Name", "Polarity"]
1713            ]
1714        ]
1715
1716        # Reorder rows by "Mass Feature ID", then "Entropy Similarity" (descending), then "Confidence Score" (descending)
1717        if "Entropy Similarity" in mf_report.columns and "Confidence Score" in mf_report.columns:
1718            mf_report = mf_report.sort_values(
1719                by=["Mass Feature ID", "Entropy Similarity", "Confidence Score"],
1720                ascending=[True, False, False],
1721            )
1722        elif "Entropy Similarity" in mf_report.columns:
1723             mf_report = mf_report.sort_values(
1724                by=["Mass Feature ID", "Entropy Similarity"],
1725                ascending=[True, False],
1726            )
1727        elif "Confidence Score" in mf_report.columns:
1728             mf_report = mf_report.sort_values(
1729                by=["Mass Feature ID", "Confidence Score"],
1730                ascending=[True, False],
1731            )
1732        # If neither "Entropy Similarity" nor "Confidence Score" are in the columns, just sort by "Mass Feature ID"
1733        else:
1734            mf_report = mf_report.sort_values("Mass Feature ID")
1735
1736        # Reset index
1737        mf_report = mf_report.reset_index(drop=True)
1738
1739        return mf_report
1740    
1741    def to_report(self, molecular_metadata=None, suppress_warnings=False):
1742        """Create a report of the mass features and their annotations.
1743
1744        Parameters
1745        ----------
1746        molecular_metadata : dict, optional
1747            The molecular metadata. Default is None.
1748        suppress_warnings : bool, optional
1749            If True, suppresses warnings from mass_features_ms2_annot_to_df.
1750            Default is False.
1751
1752        Returns
1753        -------
1754        DataFrame
1755            The report as a Pandas DataFrame.
1756        """
1757        # Get mass feature dataframe
1758        mf_report = self.mass_spectra.mass_features_to_df()
1759        mf_report = mf_report.reset_index(drop=False)
1760
1761        # Get and clean ms1 annotation dataframe
1762        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df(suppress_warnings=suppress_warnings)
1763        if ms1_annot_report is not None:
1764            ms1_annot_report = ms1_annot_report.copy()
1765            ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
1766            ms1_annot_report = ms1_annot_report.reset_index(drop=False)
1767        else:
1768            ms1_annot_report = None
1769
1770        # Get, summarize, and clean ms2 annotation dataframe
1771        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
1772            molecular_metadata=molecular_metadata,
1773            suppress_warnings=suppress_warnings
1774        )
1775        if ms2_annot_report is not None and molecular_metadata is not None:
1776            ms2_annot_report = self.summarize_metabolomics_report(ms2_annot_report)
1777            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
1778            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
1779            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
1780        else:
1781            ms2_annot_report = None
1782
1783        report = self.combine_reports(
1784            mf_report=mf_report,
1785            ms1_annot_report=ms1_annot_report,
1786            ms2_annot_report=ms2_annot_report
1787        )
1788
1789        return report

A class to export LCMS metabolite data.

This class provides methods to export LCMS metabolite data to various formats and summarize the metabolite report.

Parameters
  • out_file_path (str | Path): The output file path, do not include the file extension.
  • mass_spectra (object): The high resolution mass spectra object.
LCMSMetabolomicsExport(out_file_path, mass_spectra)
1342    def __init__(self, out_file_path, mass_spectra):
1343        super().__init__(out_file_path, mass_spectra)
1344        self.ion_type_dict = ion_type_dict

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

ion_type_dict
@staticmethod
def get_ion_formula(neutral_formula, ion_type):
1346    @staticmethod
1347    def get_ion_formula(neutral_formula, ion_type):
1348        """From a neutral formula and an ion type, return the formula of the ion.
1349
1350        Notes
1351        -----
1352        This is a static method.
1353        If the neutral_formula is not a string, this method will return None.
1354
1355        Parameters
1356        ----------
1357        neutral_formula : str
1358            The neutral formula, this should be a string form from the MolecularFormula class
1359            (e.g. 'C2 H4 O2', isotopes OK), or simple string (e.g. 'C2H4O2', no isotope handling in this case).
1360            In the case of a simple string, the atoms are parsed based on the presence of capital letters,
1361            e.g. MgCl2 is parsed as 'Mg Cl2.
1362        ion_type : str
1363            The ion type, e.g. 'protonated', '[M+H]+', '[M+Na]+', etc.
1364            See the self.ion_type_dict for the available ion types.
1365
1366        Returns
1367        -------
1368        str
1369            The formula of the ion as a string (like 'C2 H4 O2'); or None if the neutral_formula is not a string.
1370        """
1371        # If neutral_formula is not a string, return None
1372        if not isinstance(neutral_formula, str):
1373            return None
1374
1375        # Check if there are spaces in the formula (these are outputs of the MolecularFormula class and do not need to be processed before being passed to the class)
1376        if re.search(r"\s", neutral_formula):
1377            neutral_formula = MolecularFormula(neutral_formula, ion_charge=0)
1378        else:
1379            form_pre = re.sub(r"([A-Z])", r" \1", neutral_formula)[1:]
1380            elements = [re.findall(r"[A-Z][a-z]*", x) for x in form_pre.split()]
1381            counts = [re.findall(r"\d+", x) for x in form_pre.split()]
1382            neutral_formula = MolecularFormula(
1383                dict(
1384                    zip(
1385                        [x[0] for x in elements],
1386                        [int(x[0]) if x else 1 for x in counts],
1387                    )
1388                ),
1389                ion_charge=0,
1390            )
1391        neutral_formula_dict = neutral_formula.to_dict().copy()
1392
1393        adduct_add_dict = ion_type_dict[ion_type][0]
1394        for key in adduct_add_dict:
1395            if key in neutral_formula_dict.keys():
1396                neutral_formula_dict[key] += adduct_add_dict[key]
1397            else:
1398                neutral_formula_dict[key] = adduct_add_dict[key]
1399
1400        adduct_subtract = ion_type_dict[ion_type][1]
1401        for key in adduct_subtract:
1402            neutral_formula_dict[key] -= adduct_subtract[key]
1403
1404        return MolecularFormula(neutral_formula_dict, ion_charge=0).string

From a neutral formula and an ion type, return the formula of the ion.

Notes

This is a static method. If the neutral_formula is not a string, this method will return None.

Parameters
  • neutral_formula (str): The neutral formula, this should be a string form from the MolecularFormula class (e.g. 'C2 H4 O2', isotopes OK), or simple string (e.g. 'C2H4O2', no isotope handling in this case). In the case of a simple string, the atoms are parsed based on the presence of capital letters, e.g. MgCl2 is parsed as 'Mg Cl2.
  • ion_type (str): The ion type, e.g. 'protonated', '[M+H]+', '[M+Na]+', etc. See the self.ion_type_dict for the available ion types.
Returns
  • str: The formula of the ion as a string (like 'C2 H4 O2'); or None if the neutral_formula is not a string.
@staticmethod
def get_isotope_type(ion_formula):
1406    @staticmethod
1407    def get_isotope_type(ion_formula):
1408        """From an ion formula, return the 13C isotope type of the ion.
1409
1410        Notes
1411        -----
1412        This is a static method.
1413        If the ion_formula is not a string, this method will return None.
1414        This is currently only functional for 13C isotopes.
1415
1416        Parameters
1417        ----------
1418        ion_formula : str
1419            The formula of the ion, expected to be a string like 'C2 H4 O2'.
1420
1421        Returns
1422        -------
1423        str
1424            The isotope type of the ion, e.g. '13C1', '13C2', etc; or None if the ion_formula does not contain a 13C isotope.
1425
1426        Raises
1427        ------
1428        ValueError
1429            If the ion_formula is not a string.
1430        """
1431        if not isinstance(ion_formula, str):
1432            return None
1433
1434        if re.search(r"\s", ion_formula):
1435            ion_formula = MolecularFormula(ion_formula, ion_charge=0)
1436        else:
1437            raise ValueError('ion_formula should be a string like "C2 H4 O2"')
1438        ion_formula_dict = ion_formula.to_dict().copy()
1439
1440        try:
1441            iso_class = "13C" + str(ion_formula_dict.pop("13C"))
1442        except KeyError:
1443            iso_class = None
1444
1445        return iso_class

From an ion formula, return the 13C isotope type of the ion.

Notes

This is a static method. If the ion_formula is not a string, this method will return None. This is currently only functional for 13C isotopes.

Parameters
  • ion_formula (str): The formula of the ion, expected to be a string like 'C2 H4 O2'.
Returns
  • str: The isotope type of the ion, e.g. '13C1', '13C2', etc; or None if the ion_formula does not contain a 13C isotope.
Raises
  • ValueError: If the ion_formula is not a string.
def report_to_csv(self, molecular_metadata=None):
1447    def report_to_csv(self, molecular_metadata=None):
1448        """Create a report of the mass features and their annotations and save it as a CSV file.
1449
1450        Parameters
1451        ----------
1452        molecular_metadata : dict, optional
1453            The molecular metadata. Default is None.
1454        """
1455        report = self.to_report(molecular_metadata=molecular_metadata)
1456        out_file = self.output_file.with_suffix(".csv")
1457        report.to_csv(out_file, index=False)

Create a report of the mass features and their annotations and save it as a CSV file.

Parameters
  • molecular_metadata (dict, optional): The molecular metadata. Default is None.
def clean_ms1_report(self, ms1_summary_full):
1459    def clean_ms1_report(self, ms1_summary_full):
1460        """Clean the MS1 report.
1461
1462        Parameters
1463        ----------
1464        ms1_summary_full : DataFrame
1465            The full MS1 summary DataFrame.
1466
1467        Returns
1468        -------
1469        DataFrame
1470            The cleaned MS1 summary DataFrame.
1471        """
1472        ms1_summary_full = ms1_summary_full.reset_index()
1473        cols_to_keep = [
1474            "mf_id",
1475            "Molecular Formula",
1476            "Ion Type",
1477            "Calculated m/z",
1478            "m/z Error (ppm)",
1479            "m/z Error Score",
1480            "Is Isotopologue",
1481            "Isotopologue Similarity",
1482            "Confidence Score",
1483        ]
1484        ms1_summary = ms1_summary_full[cols_to_keep].copy()
1485        ms1_summary["ion_formula"] = [
1486            self.get_ion_formula(f, a)
1487            for f, a in zip(ms1_summary["Molecular Formula"], ms1_summary["Ion Type"])
1488        ]
1489        ms1_summary["isotopologue_type"] = [
1490            self.get_isotope_type(f) for f in ms1_summary["ion_formula"].tolist()
1491        ]
1492
1493        # Reorder columns
1494        ms1_summary = ms1_summary[
1495            [
1496                "mf_id",
1497                "ion_formula",
1498                "isotopologue_type",
1499                "Calculated m/z",
1500                "m/z Error (ppm)",
1501                "m/z Error Score",
1502                "Isotopologue Similarity",
1503                "Confidence Score",
1504            ]
1505        ]
1506
1507        # Set the index to mf_id
1508        ms1_summary = ms1_summary.set_index("mf_id")
1509
1510        return ms1_summary

Clean the MS1 report.

Parameters
  • ms1_summary_full (DataFrame): The full MS1 summary DataFrame.
Returns
  • DataFrame: The cleaned MS1 summary DataFrame.
def summarize_ms2_report(self, ms2_annot_report):
1512    def summarize_ms2_report(self, ms2_annot_report):
1513        """
1514        Summarize the MS2 report.
1515
1516        Parameters
1517        ----------
1518        ms2_annot_report : DataFrame
1519            The MS2 annotation DataFrame with all annotations, output of mass_features_ms2_annot_to_df.
1520        
1521        Returns
1522        -------
1523        """

Summarize the MS2 report.

Parameters
  • ms2_annot_report (DataFrame): The MS2 annotation DataFrame with all annotations, output of mass_features_ms2_annot_to_df.
  • Returns
  • -------
def summarize_metabolomics_report(self, ms2_annot_report):
1525    def summarize_metabolomics_report(self, ms2_annot_report):
1526        """Summarize the MS2 hits for a metabolomics report
1527        
1528        Parameters
1529        ----------
1530        ms2_annot : DataFrame
1531            The MS2 annotation DataFrame with all annotations.
1532
1533        Returns
1534        -------
1535        DataFrame
1536            The summarized metabolomics report.
1537        """
1538        columns_to_drop = [
1539            "precursor_mz",
1540            "precursor_mz_error_ppm",
1541            "cas",
1542            "data_id",
1543            "iupac_name",
1544            "traditional_name",
1545            "common_name",
1546            "casno",
1547        ]
1548        ms2_annot = ms2_annot_report.drop(
1549            columns=[col for col in columns_to_drop if col in ms2_annot_report.columns]
1550        )
1551        
1552        # Prepare information about the search results, pulling out the best hit for the single report
1553        # Group by mf_id,ref_mol_id grab row with highest entropy similarity
1554        ms2_annot = ms2_annot.reset_index()
1555        # Add column called "n_spectra_contributing" that is the number of unique values in query_spectrum_id per mf_id,ref_mol_id
1556        ms2_annot["n_spectra_contributing"] = (
1557            ms2_annot.groupby(["mf_id", "ref_mol_id"])["query_spectrum_id"]
1558            .transform("nunique")
1559        )
1560        # Sort by entropy similarity
1561        ms2_annot = ms2_annot.sort_values(
1562            by=["mf_id", "ref_mol_id", "entropy_similarity"], ascending=[True, True, False]
1563        )
1564        best_entropy = ms2_annot.drop_duplicates(
1565            subset=["mf_id", "ref_mol_id"], keep="first"
1566        )
1567
1568        return best_entropy

Summarize the MS2 hits for a metabolomics report

Parameters
  • ms2_annot (DataFrame): The MS2 annotation DataFrame with all annotations.
Returns
  • DataFrame: The summarized metabolomics report.
def clean_ms2_report(self, metabolite_summary):
1570    def clean_ms2_report(self, metabolite_summary):
1571        """Clean the MS2 report.
1572
1573        Parameters
1574        ----------
1575        metabolite_summary : DataFrame
1576            The full metabolomics summary DataFrame.
1577
1578        Returns
1579        -------
1580        DataFrame
1581            The cleaned metabolomics summary DataFrame.
1582        """
1583        metabolite_summary = metabolite_summary.reset_index()
1584        metabolite_summary["ion_formula"] = [
1585            self.get_ion_formula(f, a)
1586            for f, a in zip(metabolite_summary["formula"], metabolite_summary["ref_ion_type"])
1587        ]
1588
1589        col_order = [
1590            "mf_id",
1591            "ion_formula",
1592            "ref_ion_type",
1593            "formula",
1594            "inchikey",
1595            "name",
1596            "inchi",
1597            "chebi",
1598            "smiles",
1599            "kegg",
1600            "cas",
1601            "database_name",
1602            "ref_ms_id",
1603            "entropy_similarity",
1604            "ref_mz_in_query_fract",
1605            "n_spectra_contributing",
1606        ]
1607
1608        # Reorder columns
1609        metabolite_summary = metabolite_summary[
1610            [col for col in col_order if col in metabolite_summary.columns]
1611        ]
1612
1613        # Convert chebi (if present) to int:
1614        if "chebi" in metabolite_summary.columns:
1615            metabolite_summary["chebi"] = metabolite_summary["chebi"].astype(
1616                "Int64", errors="ignore"
1617            )
1618
1619        # Set the index to mf_id
1620        metabolite_summary = metabolite_summary.set_index("mf_id")
1621
1622        return metabolite_summary

Clean the MS2 report.

Parameters
  • metabolite_summary (DataFrame): The full metabolomics summary DataFrame.
Returns
  • DataFrame: The cleaned metabolomics summary DataFrame.
def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
1624    def combine_reports(self, mf_report, ms1_annot_report, ms2_annot_report):
1625        """Combine the mass feature report with the MS1 and MS2 reports.
1626
1627        Parameters
1628        ----------
1629        mf_report : DataFrame
1630            The mass feature report DataFrame.
1631        ms1_annot_report : DataFrame
1632            The MS1 annotation report DataFrame.
1633        ms2_annot_report : DataFrame
1634            The MS2 annotation report DataFrame.
1635        """
1636        # If there is an ms1_annot_report, merge it with the mf_report
1637        if ms1_annot_report is not None and not ms1_annot_report.empty:
1638            # MS1 has been run and has molecular formula information
1639            mf_report = pd.merge(
1640                mf_report,
1641                ms1_annot_report,
1642                how="left",
1643                on=["mf_id", "isotopologue_type"],
1644            )
1645        if ms2_annot_report is not None:
1646            # If both reports contain 'ion_formula', prefer a merge that respects it.
1647            # Otherwise fall back to merging on 'mf_id' only to remain robust when
1648            # MS1 formula assignment wasn't performed or MS2 summary lacks the field.
1649            if "ion_formula" in mf_report.columns and "ion_formula" in ms2_annot_report.columns:
1650                # pull out the records without ion_formula and merge on mf_id only
1651                mf_no_ion_formula = mf_report[mf_report["ion_formula"].isna()]
1652                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
1653                mf_no_ion_formula = pd.merge(
1654                    mf_no_ion_formula, ms2_annot_report, how="left", on=["mf_id"]
1655                )
1656
1657                # pull out the records with ion_formula and merge on mf_id + ion_formula
1658                mf_with_ion_formula = mf_report[~mf_report["ion_formula"].isna()]
1659                mf_with_ion_formula = pd.merge(
1660                    mf_with_ion_formula,
1661                    ms2_annot_report,
1662                    how="left",
1663                    on=["mf_id", "ion_formula"],
1664                )
1665
1666                # put back together
1667                mf_report = pd.concat([mf_no_ion_formula, mf_with_ion_formula])
1668            else:
1669                # Fall back to merging on mf_id only (robust when ion_formula missing)
1670                mf_report = pd.merge(
1671                    mf_report, ms2_annot_report, how="left", on=["mf_id"]
1672                )
1673
1674        # Rename colums
1675        rename_dict = {
1676            "mf_id": "Mass Feature ID",
1677            "scan_time": "Retention Time (min)",
1678            "mz": "m/z",
1679            "apex_scan": "Apex Scan Number",
1680            "intensity": "Intensity",
1681            "persistence": "Persistence",
1682            "area": "Area",
1683            "half_height_width": "Half Height Width (min)",
1684            "tailing_factor": "Tailing Factor",
1685            "dispersity_index": "Dispersity Index",
1686            "ms2_spectrum": "MS2 Spectrum",
1687            "monoisotopic_mf_id": "Monoisotopic Mass Feature ID",
1688            "isotopologue_type": "Isotopologue Type",
1689            "mass_spectrum_deconvoluted_parent": "Is Largest Ion after Deconvolution",
1690            "associated_mass_features": "Associated Mass Features after Deconvolution",
1691            "ion_formula": "Ion Formula",
1692            "formula": "Molecular Formula",
1693            "ref_ion_type": "Ion Type",
1694            "annot_level": "Lipid Annotation Level",
1695            "lipid_molecular_species_id": "Lipid Molecular Species",
1696            "lipid_summed_name": "Lipid Species",
1697            "lipid_subclass": "Lipid Subclass",
1698            "lipid_class": "Lipid Class",
1699            "lipid_category": "Lipid Category",
1700            "entropy_similarity": "Entropy Similarity",
1701            "ref_mz_in_query_fract": "Library mzs in Query (fraction)",
1702            "n_spectra_contributing": "Spectra with Annotation (n)",
1703        }
1704        mf_report = mf_report.rename(columns=rename_dict)
1705        mf_report["Sample Name"] = self.mass_spectra.sample_name
1706        mf_report["Polarity"] = self.mass_spectra.polarity
1707        mf_report = mf_report[
1708            ["Mass Feature ID", "Sample Name", "Polarity"]
1709            + [
1710                col
1711                for col in mf_report.columns
1712                if col not in ["Mass Feature ID", "Sample Name", "Polarity"]
1713            ]
1714        ]
1715
1716        # Reorder rows by "Mass Feature ID", then "Entropy Similarity" (descending), then "Confidence Score" (descending)
1717        if "Entropy Similarity" in mf_report.columns and "Confidence Score" in mf_report.columns:
1718            mf_report = mf_report.sort_values(
1719                by=["Mass Feature ID", "Entropy Similarity", "Confidence Score"],
1720                ascending=[True, False, False],
1721            )
1722        elif "Entropy Similarity" in mf_report.columns:
1723             mf_report = mf_report.sort_values(
1724                by=["Mass Feature ID", "Entropy Similarity"],
1725                ascending=[True, False],
1726            )
1727        elif "Confidence Score" in mf_report.columns:
1728             mf_report = mf_report.sort_values(
1729                by=["Mass Feature ID", "Confidence Score"],
1730                ascending=[True, False],
1731            )
1732        # If neither "Entropy Similarity" nor "Confidence Score" are in the columns, just sort by "Mass Feature ID"
1733        else:
1734            mf_report = mf_report.sort_values("Mass Feature ID")
1735
1736        # Reset index
1737        mf_report = mf_report.reset_index(drop=True)
1738
1739        return mf_report

Combine the mass feature report with the MS1 and MS2 reports.

Parameters
  • mf_report (DataFrame): The mass feature report DataFrame.
  • ms1_annot_report (DataFrame): The MS1 annotation report DataFrame.
  • ms2_annot_report (DataFrame): The MS2 annotation report DataFrame.
def to_report(self, molecular_metadata=None, suppress_warnings=False):
1741    def to_report(self, molecular_metadata=None, suppress_warnings=False):
1742        """Create a report of the mass features and their annotations.
1743
1744        Parameters
1745        ----------
1746        molecular_metadata : dict, optional
1747            The molecular metadata. Default is None.
1748        suppress_warnings : bool, optional
1749            If True, suppresses warnings from mass_features_ms2_annot_to_df.
1750            Default is False.
1751
1752        Returns
1753        -------
1754        DataFrame
1755            The report as a Pandas DataFrame.
1756        """
1757        # Get mass feature dataframe
1758        mf_report = self.mass_spectra.mass_features_to_df()
1759        mf_report = mf_report.reset_index(drop=False)
1760
1761        # Get and clean ms1 annotation dataframe
1762        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df(suppress_warnings=suppress_warnings)
1763        if ms1_annot_report is not None:
1764            ms1_annot_report = ms1_annot_report.copy()
1765            ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
1766            ms1_annot_report = ms1_annot_report.reset_index(drop=False)
1767        else:
1768            ms1_annot_report = None
1769
1770        # Get, summarize, and clean ms2 annotation dataframe
1771        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
1772            molecular_metadata=molecular_metadata,
1773            suppress_warnings=suppress_warnings
1774        )
1775        if ms2_annot_report is not None and molecular_metadata is not None:
1776            ms2_annot_report = self.summarize_metabolomics_report(ms2_annot_report)
1777            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
1778            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
1779            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
1780        else:
1781            ms2_annot_report = None
1782
1783        report = self.combine_reports(
1784            mf_report=mf_report,
1785            ms1_annot_report=ms1_annot_report,
1786            ms2_annot_report=ms2_annot_report
1787        )
1788
1789        return report

Create a report of the mass features and their annotations.

Parameters
  • molecular_metadata (dict, optional): The molecular metadata. Default is None.
  • suppress_warnings (bool, optional): If True, suppresses warnings from mass_features_ms2_annot_to_df. Default is False.
Returns
  • DataFrame: The report as a Pandas DataFrame.
class LipidomicsExport(LCMSMetabolomicsExport):
1790class LipidomicsExport(LCMSMetabolomicsExport):
1791    """A class to export lipidomics data.
1792
1793    This class provides methods to export lipidomics data to various formats and summarize the lipid report.
1794
1795    Parameters
1796    ----------
1797    out_file_path : str | Path
1798        The output file path, do not include the file extension.
1799    mass_spectra : object
1800        The high resolution mass spectra object.
1801    """
1802
1803    def __init__(self, out_file_path, mass_spectra):
1804        super().__init__(out_file_path, mass_spectra)
1805
1806    def summarize_lipid_report(self, ms2_annot):
1807        """Summarize the lipid report.
1808
1809        Parameters
1810        ----------
1811        ms2_annot : DataFrame
1812            The MS2 annotation DataFrame with all annotations.
1813
1814        Returns
1815        -------
1816        DataFrame
1817            The summarized lipid report.
1818        """
1819        # Drop unnecessary columns for easier viewing
1820        columns_to_drop = [
1821            "precursor_mz",
1822            "precursor_mz_error_ppm",
1823            "ref_mol_id",
1824            "ref_precursor_mz",
1825            "cas",
1826            "inchikey",
1827            "inchi",
1828            "chebi",
1829            "smiles",
1830            "kegg",
1831            "data_id",
1832            "iupac_name",
1833            "traditional_name",
1834            "common_name",
1835            "casno",
1836        ]
1837        ms2_annot = ms2_annot.drop(
1838            columns=[col for col in columns_to_drop if col in ms2_annot.columns]
1839        )
1840
1841        # If ion_types_excluded is not empty, remove those ion types
1842        ion_types_excluded = self.mass_spectra.parameters.mass_spectrum[
1843            "ms2"
1844        ].molecular_search.ion_types_excluded
1845        if len(ion_types_excluded) > 0:
1846            ms2_annot = ms2_annot[~ms2_annot["ref_ion_type"].isin(ion_types_excluded)]
1847
1848        # If mf_id is not present, check that the index name is mf_id and reset the index
1849        if "mf_id" not in ms2_annot.columns:
1850            if ms2_annot.index.name == "mf_id":
1851                ms2_annot = ms2_annot.reset_index()
1852            else:
1853                raise ValueError("mf_id is not present in the dataframe")
1854
1855        # Attempt to get consensus annotations to the MLF level
1856        mlf_results_all = []
1857        for mf_id in ms2_annot["mf_id"].unique():
1858            mlf_results_perid = []
1859            ms2_annot_mf = ms2_annot[ms2_annot["mf_id"] == mf_id].copy()
1860            ms2_annot_mf["n_spectra_contributing"] = ms2_annot_mf.query_spectrum_id.nunique()
1861
1862            for query_scan in ms2_annot["query_spectrum_id"].unique():
1863                ms2_annot_sub = ms2_annot_mf[
1864                    ms2_annot_mf["query_spectrum_id"] == query_scan
1865                ].copy()
1866
1867                if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1868                    # If there is only one lipid_summed_name, let's try to get consensus molecular species annotation
1869                    if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1870                        ms2_annot_sub["entropy_max"] = (
1871                            ms2_annot_sub["entropy_similarity"]
1872                            == ms2_annot_sub["entropy_similarity"].max()
1873                        )
1874                        ms2_annot_sub["ref_match_fract_max"] = (
1875                            ms2_annot_sub["ref_mz_in_query_fract"]
1876                            == ms2_annot_sub["ref_mz_in_query_fract"].max()
1877                        )
1878                        ms2_annot_sub["frag_max"] = ms2_annot_sub[
1879                            "query_frag_types"
1880                        ].apply(lambda x: True if "MLF" in x else False)
1881
1882                        # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1883                        ms2_annot_sub["consensus"] = ms2_annot_sub[
1884                            ["entropy_max", "ref_match_fract_max", "frag_max"]
1885                        ].all(axis=1)
1886
1887                        # If there is a consensus, take the row with the highest entropy_similarity
1888                        if ms2_annot_sub["consensus"].any():
1889                            ms2_annot_sub = ms2_annot_sub[
1890                                ms2_annot_sub["entropy_similarity"]
1891                                == ms2_annot_sub["entropy_similarity"].max()
1892                            ].head(1)
1893                            mlf_results_perid.append(ms2_annot_sub)
1894            if len(mlf_results_perid) == 0:
1895                mlf_results_perid = pd.DataFrame()
1896            else:
1897                mlf_results_perid = pd.concat(mlf_results_perid)
1898                if mlf_results_perid["name"].nunique() == 1:
1899                    mlf_results_perid = mlf_results_perid[
1900                        mlf_results_perid["entropy_similarity"]
1901                        == mlf_results_perid["entropy_similarity"].max()
1902                    ].head(1)
1903                else:
1904                    mlf_results_perid = pd.DataFrame()
1905                mlf_results_all.append(mlf_results_perid)
1906
1907        # These are the consensus annotations to the MLF level
1908        if len(mlf_results_all) > 0:
1909            mlf_results_all = pd.concat(mlf_results_all)
1910            mlf_results_all["annot_level"] = mlf_results_all["structure_level"]
1911        else:
1912            # Make an empty dataframe
1913            mlf_results_all = ms2_annot.head(0)
1914
1915        # For remaining mf_ids, try to get a consensus annotation to the species level
1916        species_results_all = []
1917        # Remove mf_ids that have consensus annotations to the MLF level
1918        ms2_annot_spec = ms2_annot[
1919            ~ms2_annot["mf_id"].isin(mlf_results_all["mf_id"].unique())
1920        ]
1921        for mf_id in ms2_annot_spec["mf_id"].unique():
1922            # Do all the hits have the same lipid_summed_name?
1923            ms2_annot_sub = ms2_annot_spec[ms2_annot_spec["mf_id"] == mf_id].copy()
1924            ms2_annot_sub["n_spectra_contributing"] = len(ms2_annot_sub)
1925
1926            if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1927                # Grab the highest entropy_similarity result
1928                ms2_annot_sub = ms2_annot_sub[
1929                    ms2_annot_sub["entropy_similarity"]
1930                    == ms2_annot_sub["entropy_similarity"].max()
1931                ].head(1)
1932                species_results_all.append(ms2_annot_sub)
1933
1934        # These are the consensus annotations to the species level
1935        if len(species_results_all) > 0:
1936            species_results_all = pd.concat(species_results_all)
1937            species_results_all["annot_level"] = "species"
1938        else:
1939            # Make an empty dataframe
1940            species_results_all = ms2_annot.head(0)
1941
1942        # Deal with the remaining mf_ids that do not have consensus annotations to the species level or MLF level
1943        # Remove mf_ids that have consensus annotations to the species level
1944        ms2_annot_remaining = ms2_annot_spec[
1945            ~ms2_annot_spec["mf_id"].isin(species_results_all["mf_id"].unique())
1946        ]
1947        no_consensus = []
1948        for mf_id in ms2_annot_remaining["mf_id"].unique():
1949            id_sub = []
1950            id_no_con = []
1951            ms2_annot_sub_mf = ms2_annot_remaining[
1952                ms2_annot_remaining["mf_id"] == mf_id
1953            ].copy()
1954            for query_scan in ms2_annot_sub_mf["query_spectrum_id"].unique():
1955                ms2_annot_sub = ms2_annot_sub_mf[
1956                    ms2_annot_sub_mf["query_spectrum_id"] == query_scan
1957                ].copy()
1958
1959                # New columns for ranking [HIGHER RANK = BETTER]
1960                ms2_annot_sub["entropy_max"] = (
1961                    ms2_annot_sub["entropy_similarity"]
1962                    == ms2_annot_sub["entropy_similarity"].max()
1963                )
1964                ms2_annot_sub["ref_match_fract_max"] = (
1965                    ms2_annot_sub["ref_mz_in_query_fract"]
1966                    == ms2_annot_sub["ref_mz_in_query_fract"].max()
1967                )
1968                ms2_annot_sub["frag_max"] = ms2_annot_sub["query_frag_types"].apply(
1969                    lambda x: True if "MLF" in x else False
1970                )
1971
1972                # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1973                ms2_annot_sub["consensus"] = ms2_annot_sub[
1974                    ["entropy_max", "ref_match_fract_max", "frag_max"]
1975                ].all(axis=1)
1976                ms2_annot_sub_con = ms2_annot_sub[ms2_annot_sub["consensus"]]
1977                id_sub.append(ms2_annot_sub_con)
1978                id_no_con.append(ms2_annot_sub)
1979            id_sub = pd.concat(id_sub)
1980            id_no_con = pd.concat(id_no_con)
1981
1982            # Scenario 1: Multiple scans are being resolved to different MLFs [could be coelutions and should both be kept and annotated to MS level]
1983            if (
1984                id_sub["query_frag_types"]
1985                .apply(lambda x: True if "MLF" in x else False)
1986                .all()
1987                and len(id_sub) > 0
1988            ):
1989                idx = id_sub.groupby("name")["entropy_similarity"].idxmax()
1990                id_sub = id_sub.loc[idx]
1991                # Reorder so highest entropy_similarity is first
1992                id_sub = id_sub.sort_values("entropy_similarity", ascending=False)
1993                id_sub["annot_level"] = id_sub["structure_level"]
1994                no_consensus.append(id_sub)
1995
1996            # Scenario 2: Multiple scans are being resolved to different species, keep both and annotate to appropriate level
1997            elif len(id_sub) == 0:
1998                for lipid_summed_name in id_no_con["lipid_summed_name"].unique():
1999                    summed_sub = id_no_con[
2000                        id_no_con["lipid_summed_name"] == lipid_summed_name
2001                    ]
2002                    # Any consensus to MLF?
2003                    if summed_sub["consensus"].any():
2004                        summed_sub = summed_sub[summed_sub["consensus"]]
2005                        summed_sub["annot_level"] = summed_sub["structure_level"]
2006                        no_consensus.append(summed_sub)
2007                    else:
2008                        # Grab the highest entropy_similarity, if there are multiple, grab the first one
2009                        summed_sub = summed_sub[
2010                            summed_sub["entropy_similarity"]
2011                            == summed_sub["entropy_similarity"].max()
2012                        ].head(1)
2013                        # get first row
2014                        summed_sub["annot_level"] = "species"
2015                        summed_sub["name"] = ""
2016                        no_consensus.append(summed_sub)
2017            else:
2018                raise ValueError("Unexpected scenario for summarizing mf_id: ", mf_id)
2019
2020        if len(no_consensus) > 0:
2021            no_consensus = pd.concat(no_consensus)
2022        else:
2023            no_consensus = ms2_annot.head(0)
2024
2025        # Combine all the consensus annotations and reformat the dataframe for output
2026        species_results_all = species_results_all.drop(columns=["name"])
2027        species_results_all["lipid_molecular_species_id"] = ""
2028        mlf_results_all["lipid_molecular_species_id"] = mlf_results_all["name"]
2029        no_consensus["lipid_molecular_species_id"] = no_consensus["name"]
2030        consensus_annotations = pd.concat(
2031            [mlf_results_all, species_results_all, no_consensus]
2032        )
2033        consensus_annotations = consensus_annotations.sort_values(
2034            "mf_id", ascending=True
2035        )
2036        cols_to_keep = [
2037            "mf_id",
2038            "ref_ion_type",
2039            "entropy_similarity",
2040            "ref_mz_in_query_fract",
2041            "lipid_molecular_species_id",
2042            "lipid_summed_name",
2043            "lipid_subclass",
2044            "lipid_class",
2045            "lipid_category",
2046            "formula",
2047            "annot_level",
2048            "n_spectra_contributing",
2049        ]
2050        consensus_annotations = consensus_annotations[cols_to_keep]
2051        consensus_annotations = consensus_annotations.set_index("mf_id")
2052
2053        return consensus_annotations
2054
2055    def clean_ms2_report(self, lipid_summary):
2056        """Clean the MS2 report.
2057
2058        Parameters
2059        ----------
2060        lipid_summary : DataFrame
2061            The full lipid summary DataFrame.
2062
2063        Returns
2064        -------
2065        DataFrame
2066            The cleaned lipid summary DataFrame.
2067        """
2068        lipid_summary = lipid_summary.reset_index()
2069        lipid_summary["ion_formula"] = [
2070            self.get_ion_formula(f, a)
2071            for f, a in zip(lipid_summary["formula"], lipid_summary["ref_ion_type"])
2072        ]
2073
2074        # Reorder columns
2075        lipid_summary = lipid_summary[
2076            [
2077                "mf_id",
2078                "ion_formula",
2079                "ref_ion_type",
2080                "formula",
2081                "annot_level",
2082                "lipid_molecular_species_id",
2083                "lipid_summed_name",
2084                "lipid_subclass",
2085                "lipid_class",
2086                "lipid_category",
2087                "entropy_similarity",
2088                "ref_mz_in_query_fract",
2089                "n_spectra_contributing",
2090            ]
2091        ]
2092
2093        # Set the index to mf_id
2094        lipid_summary = lipid_summary.set_index("mf_id")
2095
2096        return lipid_summary
2097
2098    def to_report(self, molecular_metadata=None):
2099        """Create a report of the mass features and their annotations.
2100
2101        Parameters
2102        ----------
2103        molecular_metadata : dict, optional
2104            The molecular metadata. Default is None.
2105
2106        Returns
2107        -------
2108        DataFrame
2109            The report of the mass features and their annotations.
2110
2111        Notes
2112        -----
2113        The report will contain the mass features and their annotations from MS1 and MS2 (if available).
2114        """
2115        # Get mass feature dataframe
2116        mf_report = self.mass_spectra.mass_features_to_df()
2117        mf_report = mf_report.reset_index(drop=False)
2118
2119        # Get and clean ms1 annotation dataframe
2120        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df().copy()
2121        ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
2122        ms1_annot_report = ms1_annot_report.reset_index(drop=False)
2123
2124        # Get, summarize, and clean ms2 annotation dataframe
2125        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
2126            molecular_metadata=molecular_metadata
2127        )
2128        if ms2_annot_report is not None and molecular_metadata is not None:
2129            ms2_annot_report = self.summarize_lipid_report(ms2_annot_report)
2130            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
2131            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
2132            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
2133        report = self.combine_reports(
2134            mf_report=mf_report,
2135            ms1_annot_report=ms1_annot_report,
2136            ms2_annot_report=ms2_annot_report
2137        )
2138        return report

A class to export lipidomics data.

This class provides methods to export lipidomics data to various formats and summarize the lipid report.

Parameters
  • out_file_path (str | Path): The output file path, do not include the file extension.
  • mass_spectra (object): The high resolution mass spectra object.
LipidomicsExport(out_file_path, mass_spectra)
1803    def __init__(self, out_file_path, mass_spectra):
1804        super().__init__(out_file_path, mass_spectra)

This constructor should always be called with keyword arguments. Arguments are:

group should be None; reserved for future extension when a ThreadGroup class is implemented.

target is the callable object to be invoked by the run() method. Defaults to None, meaning nothing is called.

name is the thread name. By default, a unique name is constructed of the form "Thread-N" where N is a small decimal number.

args is a list or tuple of arguments for the target invocation. Defaults to ().

kwargs is a dictionary of keyword arguments for the target invocation. Defaults to {}.

If a subclass overrides the constructor, it must make sure to invoke the base class constructor (Thread.__init__()) before doing anything else to the thread.

def summarize_lipid_report(self, ms2_annot):
1806    def summarize_lipid_report(self, ms2_annot):
1807        """Summarize the lipid report.
1808
1809        Parameters
1810        ----------
1811        ms2_annot : DataFrame
1812            The MS2 annotation DataFrame with all annotations.
1813
1814        Returns
1815        -------
1816        DataFrame
1817            The summarized lipid report.
1818        """
1819        # Drop unnecessary columns for easier viewing
1820        columns_to_drop = [
1821            "precursor_mz",
1822            "precursor_mz_error_ppm",
1823            "ref_mol_id",
1824            "ref_precursor_mz",
1825            "cas",
1826            "inchikey",
1827            "inchi",
1828            "chebi",
1829            "smiles",
1830            "kegg",
1831            "data_id",
1832            "iupac_name",
1833            "traditional_name",
1834            "common_name",
1835            "casno",
1836        ]
1837        ms2_annot = ms2_annot.drop(
1838            columns=[col for col in columns_to_drop if col in ms2_annot.columns]
1839        )
1840
1841        # If ion_types_excluded is not empty, remove those ion types
1842        ion_types_excluded = self.mass_spectra.parameters.mass_spectrum[
1843            "ms2"
1844        ].molecular_search.ion_types_excluded
1845        if len(ion_types_excluded) > 0:
1846            ms2_annot = ms2_annot[~ms2_annot["ref_ion_type"].isin(ion_types_excluded)]
1847
1848        # If mf_id is not present, check that the index name is mf_id and reset the index
1849        if "mf_id" not in ms2_annot.columns:
1850            if ms2_annot.index.name == "mf_id":
1851                ms2_annot = ms2_annot.reset_index()
1852            else:
1853                raise ValueError("mf_id is not present in the dataframe")
1854
1855        # Attempt to get consensus annotations to the MLF level
1856        mlf_results_all = []
1857        for mf_id in ms2_annot["mf_id"].unique():
1858            mlf_results_perid = []
1859            ms2_annot_mf = ms2_annot[ms2_annot["mf_id"] == mf_id].copy()
1860            ms2_annot_mf["n_spectra_contributing"] = ms2_annot_mf.query_spectrum_id.nunique()
1861
1862            for query_scan in ms2_annot["query_spectrum_id"].unique():
1863                ms2_annot_sub = ms2_annot_mf[
1864                    ms2_annot_mf["query_spectrum_id"] == query_scan
1865                ].copy()
1866
1867                if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1868                    # If there is only one lipid_summed_name, let's try to get consensus molecular species annotation
1869                    if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1870                        ms2_annot_sub["entropy_max"] = (
1871                            ms2_annot_sub["entropy_similarity"]
1872                            == ms2_annot_sub["entropy_similarity"].max()
1873                        )
1874                        ms2_annot_sub["ref_match_fract_max"] = (
1875                            ms2_annot_sub["ref_mz_in_query_fract"]
1876                            == ms2_annot_sub["ref_mz_in_query_fract"].max()
1877                        )
1878                        ms2_annot_sub["frag_max"] = ms2_annot_sub[
1879                            "query_frag_types"
1880                        ].apply(lambda x: True if "MLF" in x else False)
1881
1882                        # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1883                        ms2_annot_sub["consensus"] = ms2_annot_sub[
1884                            ["entropy_max", "ref_match_fract_max", "frag_max"]
1885                        ].all(axis=1)
1886
1887                        # If there is a consensus, take the row with the highest entropy_similarity
1888                        if ms2_annot_sub["consensus"].any():
1889                            ms2_annot_sub = ms2_annot_sub[
1890                                ms2_annot_sub["entropy_similarity"]
1891                                == ms2_annot_sub["entropy_similarity"].max()
1892                            ].head(1)
1893                            mlf_results_perid.append(ms2_annot_sub)
1894            if len(mlf_results_perid) == 0:
1895                mlf_results_perid = pd.DataFrame()
1896            else:
1897                mlf_results_perid = pd.concat(mlf_results_perid)
1898                if mlf_results_perid["name"].nunique() == 1:
1899                    mlf_results_perid = mlf_results_perid[
1900                        mlf_results_perid["entropy_similarity"]
1901                        == mlf_results_perid["entropy_similarity"].max()
1902                    ].head(1)
1903                else:
1904                    mlf_results_perid = pd.DataFrame()
1905                mlf_results_all.append(mlf_results_perid)
1906
1907        # These are the consensus annotations to the MLF level
1908        if len(mlf_results_all) > 0:
1909            mlf_results_all = pd.concat(mlf_results_all)
1910            mlf_results_all["annot_level"] = mlf_results_all["structure_level"]
1911        else:
1912            # Make an empty dataframe
1913            mlf_results_all = ms2_annot.head(0)
1914
1915        # For remaining mf_ids, try to get a consensus annotation to the species level
1916        species_results_all = []
1917        # Remove mf_ids that have consensus annotations to the MLF level
1918        ms2_annot_spec = ms2_annot[
1919            ~ms2_annot["mf_id"].isin(mlf_results_all["mf_id"].unique())
1920        ]
1921        for mf_id in ms2_annot_spec["mf_id"].unique():
1922            # Do all the hits have the same lipid_summed_name?
1923            ms2_annot_sub = ms2_annot_spec[ms2_annot_spec["mf_id"] == mf_id].copy()
1924            ms2_annot_sub["n_spectra_contributing"] = len(ms2_annot_sub)
1925
1926            if ms2_annot_sub["lipid_summed_name"].nunique() == 1:
1927                # Grab the highest entropy_similarity result
1928                ms2_annot_sub = ms2_annot_sub[
1929                    ms2_annot_sub["entropy_similarity"]
1930                    == ms2_annot_sub["entropy_similarity"].max()
1931                ].head(1)
1932                species_results_all.append(ms2_annot_sub)
1933
1934        # These are the consensus annotations to the species level
1935        if len(species_results_all) > 0:
1936            species_results_all = pd.concat(species_results_all)
1937            species_results_all["annot_level"] = "species"
1938        else:
1939            # Make an empty dataframe
1940            species_results_all = ms2_annot.head(0)
1941
1942        # Deal with the remaining mf_ids that do not have consensus annotations to the species level or MLF level
1943        # Remove mf_ids that have consensus annotations to the species level
1944        ms2_annot_remaining = ms2_annot_spec[
1945            ~ms2_annot_spec["mf_id"].isin(species_results_all["mf_id"].unique())
1946        ]
1947        no_consensus = []
1948        for mf_id in ms2_annot_remaining["mf_id"].unique():
1949            id_sub = []
1950            id_no_con = []
1951            ms2_annot_sub_mf = ms2_annot_remaining[
1952                ms2_annot_remaining["mf_id"] == mf_id
1953            ].copy()
1954            for query_scan in ms2_annot_sub_mf["query_spectrum_id"].unique():
1955                ms2_annot_sub = ms2_annot_sub_mf[
1956                    ms2_annot_sub_mf["query_spectrum_id"] == query_scan
1957                ].copy()
1958
1959                # New columns for ranking [HIGHER RANK = BETTER]
1960                ms2_annot_sub["entropy_max"] = (
1961                    ms2_annot_sub["entropy_similarity"]
1962                    == ms2_annot_sub["entropy_similarity"].max()
1963                )
1964                ms2_annot_sub["ref_match_fract_max"] = (
1965                    ms2_annot_sub["ref_mz_in_query_fract"]
1966                    == ms2_annot_sub["ref_mz_in_query_fract"].max()
1967                )
1968                ms2_annot_sub["frag_max"] = ms2_annot_sub["query_frag_types"].apply(
1969                    lambda x: True if "MLF" in x else False
1970                )
1971
1972                # New column that looks if there is a consensus between the ranks (one row that is highest in all ranks)
1973                ms2_annot_sub["consensus"] = ms2_annot_sub[
1974                    ["entropy_max", "ref_match_fract_max", "frag_max"]
1975                ].all(axis=1)
1976                ms2_annot_sub_con = ms2_annot_sub[ms2_annot_sub["consensus"]]
1977                id_sub.append(ms2_annot_sub_con)
1978                id_no_con.append(ms2_annot_sub)
1979            id_sub = pd.concat(id_sub)
1980            id_no_con = pd.concat(id_no_con)
1981
1982            # Scenario 1: Multiple scans are being resolved to different MLFs [could be coelutions and should both be kept and annotated to MS level]
1983            if (
1984                id_sub["query_frag_types"]
1985                .apply(lambda x: True if "MLF" in x else False)
1986                .all()
1987                and len(id_sub) > 0
1988            ):
1989                idx = id_sub.groupby("name")["entropy_similarity"].idxmax()
1990                id_sub = id_sub.loc[idx]
1991                # Reorder so highest entropy_similarity is first
1992                id_sub = id_sub.sort_values("entropy_similarity", ascending=False)
1993                id_sub["annot_level"] = id_sub["structure_level"]
1994                no_consensus.append(id_sub)
1995
1996            # Scenario 2: Multiple scans are being resolved to different species, keep both and annotate to appropriate level
1997            elif len(id_sub) == 0:
1998                for lipid_summed_name in id_no_con["lipid_summed_name"].unique():
1999                    summed_sub = id_no_con[
2000                        id_no_con["lipid_summed_name"] == lipid_summed_name
2001                    ]
2002                    # Any consensus to MLF?
2003                    if summed_sub["consensus"].any():
2004                        summed_sub = summed_sub[summed_sub["consensus"]]
2005                        summed_sub["annot_level"] = summed_sub["structure_level"]
2006                        no_consensus.append(summed_sub)
2007                    else:
2008                        # Grab the highest entropy_similarity, if there are multiple, grab the first one
2009                        summed_sub = summed_sub[
2010                            summed_sub["entropy_similarity"]
2011                            == summed_sub["entropy_similarity"].max()
2012                        ].head(1)
2013                        # get first row
2014                        summed_sub["annot_level"] = "species"
2015                        summed_sub["name"] = ""
2016                        no_consensus.append(summed_sub)
2017            else:
2018                raise ValueError("Unexpected scenario for summarizing mf_id: ", mf_id)
2019
2020        if len(no_consensus) > 0:
2021            no_consensus = pd.concat(no_consensus)
2022        else:
2023            no_consensus = ms2_annot.head(0)
2024
2025        # Combine all the consensus annotations and reformat the dataframe for output
2026        species_results_all = species_results_all.drop(columns=["name"])
2027        species_results_all["lipid_molecular_species_id"] = ""
2028        mlf_results_all["lipid_molecular_species_id"] = mlf_results_all["name"]
2029        no_consensus["lipid_molecular_species_id"] = no_consensus["name"]
2030        consensus_annotations = pd.concat(
2031            [mlf_results_all, species_results_all, no_consensus]
2032        )
2033        consensus_annotations = consensus_annotations.sort_values(
2034            "mf_id", ascending=True
2035        )
2036        cols_to_keep = [
2037            "mf_id",
2038            "ref_ion_type",
2039            "entropy_similarity",
2040            "ref_mz_in_query_fract",
2041            "lipid_molecular_species_id",
2042            "lipid_summed_name",
2043            "lipid_subclass",
2044            "lipid_class",
2045            "lipid_category",
2046            "formula",
2047            "annot_level",
2048            "n_spectra_contributing",
2049        ]
2050        consensus_annotations = consensus_annotations[cols_to_keep]
2051        consensus_annotations = consensus_annotations.set_index("mf_id")
2052
2053        return consensus_annotations

Summarize the lipid report.

Parameters
  • ms2_annot (DataFrame): The MS2 annotation DataFrame with all annotations.
Returns
  • DataFrame: The summarized lipid report.
def clean_ms2_report(self, lipid_summary):
2055    def clean_ms2_report(self, lipid_summary):
2056        """Clean the MS2 report.
2057
2058        Parameters
2059        ----------
2060        lipid_summary : DataFrame
2061            The full lipid summary DataFrame.
2062
2063        Returns
2064        -------
2065        DataFrame
2066            The cleaned lipid summary DataFrame.
2067        """
2068        lipid_summary = lipid_summary.reset_index()
2069        lipid_summary["ion_formula"] = [
2070            self.get_ion_formula(f, a)
2071            for f, a in zip(lipid_summary["formula"], lipid_summary["ref_ion_type"])
2072        ]
2073
2074        # Reorder columns
2075        lipid_summary = lipid_summary[
2076            [
2077                "mf_id",
2078                "ion_formula",
2079                "ref_ion_type",
2080                "formula",
2081                "annot_level",
2082                "lipid_molecular_species_id",
2083                "lipid_summed_name",
2084                "lipid_subclass",
2085                "lipid_class",
2086                "lipid_category",
2087                "entropy_similarity",
2088                "ref_mz_in_query_fract",
2089                "n_spectra_contributing",
2090            ]
2091        ]
2092
2093        # Set the index to mf_id
2094        lipid_summary = lipid_summary.set_index("mf_id")
2095
2096        return lipid_summary

Clean the MS2 report.

Parameters
  • lipid_summary (DataFrame): The full lipid summary DataFrame.
Returns
  • DataFrame: The cleaned lipid summary DataFrame.
def to_report(self, molecular_metadata=None):
2098    def to_report(self, molecular_metadata=None):
2099        """Create a report of the mass features and their annotations.
2100
2101        Parameters
2102        ----------
2103        molecular_metadata : dict, optional
2104            The molecular metadata. Default is None.
2105
2106        Returns
2107        -------
2108        DataFrame
2109            The report of the mass features and their annotations.
2110
2111        Notes
2112        -----
2113        The report will contain the mass features and their annotations from MS1 and MS2 (if available).
2114        """
2115        # Get mass feature dataframe
2116        mf_report = self.mass_spectra.mass_features_to_df()
2117        mf_report = mf_report.reset_index(drop=False)
2118
2119        # Get and clean ms1 annotation dataframe
2120        ms1_annot_report = self.mass_spectra.mass_features_ms1_annot_to_df().copy()
2121        ms1_annot_report = self.clean_ms1_report(ms1_annot_report)
2122        ms1_annot_report = ms1_annot_report.reset_index(drop=False)
2123
2124        # Get, summarize, and clean ms2 annotation dataframe
2125        ms2_annot_report = self.mass_spectra.mass_features_ms2_annot_to_df(
2126            molecular_metadata=molecular_metadata
2127        )
2128        if ms2_annot_report is not None and molecular_metadata is not None:
2129            ms2_annot_report = self.summarize_lipid_report(ms2_annot_report)
2130            ms2_annot_report = self.clean_ms2_report(ms2_annot_report)
2131            ms2_annot_report = ms2_annot_report.dropna(axis=1, how="all")
2132            ms2_annot_report = ms2_annot_report.reset_index(drop=False)
2133        report = self.combine_reports(
2134            mf_report=mf_report,
2135            ms1_annot_report=ms1_annot_report,
2136            ms2_annot_report=ms2_annot_report
2137        )
2138        return report

Create a report of the mass features and their annotations.

Parameters
  • molecular_metadata (dict, optional): The molecular metadata. Default is None.
Returns
  • DataFrame: The report of the mass features and their annotations.
Notes

The report will contain the mass features and their annotations from MS1 and MS2 (if available).

class LCMSCollectionExport:
2141class LCMSCollectionExport():
2142    """A class to export an LCMS collection to HDF5 format.
2143
2144    This class provides methods to export collection-level data from multi-sample LC-MS
2145    experiments to HDF5 files. It handles the export of metadata, retention time alignments,
2146    cluster assignments, and induced mass features (gap-filled features) across the collection.
2147
2148    The exporter is designed to work with LCMSCollection objects and complements the individual
2149    LCMSExport class by focusing on collection-wide data rather than individual sample data.
2150
2151    Parameters
2152    ----------
2153    out_file_path : str | Path
2154        The output file path, do not include the file extension. The .hdf5 extension
2155        will be added automatically.
2156    mass_spectra_collection : LCMSCollection
2157        The LCMS collection object containing multiple LCMS samples with processed mass features,
2158        alignments, and clustering information.
2159
2160    Attributes
2161    ----------
2162    out_file_path : Path
2163        The output file path as a Path object.
2164    mass_spectra_collection : LCMSCollection
2165        The LCMS collection object to be exported.
2166
2167    Methods
2168    -------
2169    export_to_hdf5(overwrite=False)
2170        Export the LCMS collection to an HDF5 file with collection-level data.
2171
2172    Notes
2173    -----
2174    This class exports collection-level data including:
2175    - Sample manifest (metadata about all samples in the collection)
2176    - Retention time alignment data (if RT alignment has been performed)
2177    - Cluster assignments (consensus mass feature groupings across samples)
2178    - Induced mass features (gap-filled features saved to individual LCMS object HDF5 files)
2179
2180    Individual sample data (mass spectra, mass features, EICs, etc.) should be exported
2181    separately using the LCMSExport class for each LCMS object in the collection.
2182
2183    Examples
2184    --------
2185    Export a collection after clustering and gap-filling:
2186
2187    >>> from corems.mass_spectra.output.export import LCMSCollectionExporter
2188    >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
2189    >>> exporter.export_to_hdf5(overwrite=True)
2190
2191    The resulting HDF5 file will contain collection-level metadata and can be used
2192    to reconstruct the collection state for further analysis.
2193
2194    See Also
2195    --------
2196    LCMSExport : Export individual LCMS objects to HDF5
2197    LCMSCollection : The collection object being exported
2198    """
2199    def __init__(self, out_file_path, mass_spectra_collection):
2200        self.out_file_path = Path(out_file_path)
2201        self.mass_spectra_collection = mass_spectra_collection
2202
2203    def export_to_hdf5(
2204            self, 
2205            overwrite = False, 
2206            save_parameters=True, 
2207            parameter_format="toml",
2208            update_lcms_objects=True):
2209        """Export the LCMS collection to an HDF5 file.
2210        
2211        This method saves the collection-level data to an HDF5 file, including:
2212        - Basic metadata (date, folder location, gap-filling status)
2213        - Sample manifest
2214        - Retention time alignments (if available)
2215        - Cluster assignments (if available)
2216        - Induced mass features for each LCMS object (if gap-filling was performed)
2217        
2218        Individual LCMS objects in the collection are not exported by this method.
2219        Use LCMSExport for exporting individual LCMS objects.
2220        
2221        Parameters
2222        ----------
2223        overwrite : bool, optional
2224            If True, overwrites the output file if it already exists and replaces
2225            existing groups within the HDF5 file. If False, appends new data to
2226            existing file without overwriting existing groups. Default is False.
2227        save_parameters : bool, optional
2228            If True, saves the collection-level parameters to a separate file in the specified format.
2229            Default is True.
2230        parameter_format : str, optional
2231            The format for saving parameters, either "json" or "toml". Default is "toml".
2232        update_lcms_objects : bool, optional
2233            If True, updates the individual LCMS object HDF5 files with new raw file locations and any additional 
2234            information produced during the processing of the collection (e.g. cluster mass feature associations). Default is True.
2235            
2236        Notes
2237        -----
2238        The HDF5 file structure includes:
2239        - Attributes: date_utc, lcms_objects_folder, missing_mass_features_searched, manifest
2240        - Groups: rt_alignments, cluster_assignments (if available)
2241        
2242        Induced mass features are saved to the individual LCMS object HDF5 files
2243        within the .corems folder structure, not in the collection-level HDF5 file.
2244        
2245        Examples
2246        --------
2247        >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
2248        >>> exporter.export_to_hdf5(overwrite=True)
2249        """
2250        if overwrite:
2251            if self.out_file_path.with_suffix(".hdf5").exists():
2252                self.out_file_path.with_suffix(".hdf5").unlink()
2253
2254        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2255            # Add basic attributes to the HDF5 file, always overwrite these
2256            timenow = str(
2257                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
2258                )
2259            hdf_handle.attrs["date_utc"] = timenow
2260            hdf_handle.attrs["lcms_objects_folder"] = str(self.mass_spectra_collection.collection_parser.folder_location)
2261            hdf_handle.attrs["missing_mass_features_searched"] = self.mass_spectra_collection.missing_mass_features_searched
2262            hdf_handle.attrs["rt_aligned"] = self.mass_spectra_collection.rt_aligned
2263            hdf_handle.attrs["rt_alignment_attempted"] = self.mass_spectra_collection.rt_alignment_attempted
2264
2265            # Add the manifest to the HDF5 file, always overwrite this
2266            hdf_handle.attrs["manifest"] = self._convert_manifest_to_json()
2267
2268            # Save retention time alignments if they exist, only overwrite if specified
2269            self._save_rt_alignments_to_hdf5(hdf_handle, overwrite)
2270
2271            # Save cluster assignments if they exist, only overwrite if specified
2272            self._save_cluster_assignments_to_hdf5(hdf_handle, overwrite)
2273
2274        # Save new raw file locations to each LCMS object's HDF5 file if needed
2275        if hasattr(self.mass_spectra_collection, 'raw_files_relocated') and self.mass_spectra_collection.raw_files_relocated:
2276            self._update_raw_file_locations_in_hdf5()
2277
2278        # Save induced mass features to the collection with associations to each individual, only if lcms_collection.missing_mass_features_searched is True
2279        if self.mass_spectra_collection.missing_mass_features_searched:
2280            self._save_induced_mass_features_to_hdf5(overwrite)
2281            # Save EICs for induced mass features at collection level
2282            self._save_induced_eics_to_hdf5(overwrite)
2283        
2284        # Build cluster mass feature map to know which features to update
2285        # This uses the same logic as process_consensus_features to determine loaded features
2286        cluster_mf_map = self._build_cluster_mf_map()
2287        
2288        # Save updated mass features for each LCMS object
2289        # This implements selective update: only loaded features are updated, non-cluster features are preserved
2290        if update_lcms_objects:
2291            self._save_lcms_objects_to_hdf5(cluster_mf_map, overwrite)
2292
2293        # Save collection-level parameters as separate file
2294        if save_parameters:
2295            # Check if parameter_format is valid
2296            if parameter_format not in ["json", "toml"]:
2297                raise ValueError("parameter_format must be 'json' or 'toml'")
2298
2299            if parameter_format == "json":
2300                dump_lcms_collection_settings_json(
2301                    filename=self.out_file_path.with_suffix(".json"),
2302                    lcms_collection=self.mass_spectra_collection,
2303                )
2304            elif parameter_format == "toml":
2305                dump_lcms_collection_settings_toml(
2306                    filename=self.out_file_path.with_suffix(".toml"),
2307                    lcms_collection=self.mass_spectra_collection,
2308                )
2309
2310    def _save_rt_alignments_to_hdf5(self, hdf_handle, overwrite):
2311        """Save retention time alignments to HDF5 file."""
2312        # If no rt_alignments, return early
2313        if not self.mass_spectra_collection.rt_aligned:
2314            return
2315        
2316        # If rt_alignments exist, save them
2317        if self.mass_spectra_collection.rt_aligned:
2318            group_name = "rt_alignments"
2319            # grab dictionary of rt_alignments
2320            rt_alignments = self.mass_spectra_collection.rt_alignments
2321
2322        if rt_alignments:
2323            # Check if group exists and handle overwrite logic
2324            if group_name in hdf_handle:
2325                if not overwrite:
2326                    return
2327                del hdf_handle[group_name]
2328            
2329            grp = hdf_handle.create_group(group_name)
2330            
2331            # Save each alignment as a dataset
2332            for sample_idx, alignment_data in rt_alignments.items():
2333                grp.create_dataset(str(sample_idx), data=alignment_data)
2334    
2335    def _convert_manifest_to_json(self):
2336        """Clean the manifest for export to HDF5."""
2337        manifest = self.mass_spectra_collection.collection_parser.manifest
2338        
2339        # Process the manifest to convert numpy.bool_ or bool values for the 'use_rt_alignment' key
2340        def convert_bool_values(data):
2341            if isinstance(data, dict):
2342                # Process each key-value pair recursively
2343                return {k: (int(v) if k == 'use_rt_alignment' and isinstance(v, (bool, np.bool_)) else convert_bool_values(v)) for k, v in data.items()}
2344            elif isinstance(data, list):
2345                # Recursively process lists
2346                return [convert_bool_values(item) for item in data]
2347            else:
2348                # Return non-dict/list types unchanged
2349                return data
2350        
2351        # Clean the whole manifest
2352        cleaned_manifest = convert_bool_values(manifest)
2353
2354        # Serialize the cleaned manifest into JSON format
2355        json_manifest = json.dumps(cleaned_manifest)
2356        return json_manifest
2357    
2358    def _save_cluster_assignments_to_hdf5(self, hdf_handle, overwrite):
2359        """Save cluster assignments to HDF5 file."""
2360        # Check if column "cluster" is present in self.mass_features_dataframe
2361        if "cluster" in self.mass_spectra_collection.mass_features_dataframe.columns:
2362            group_name = "cluster_assignments"
2363            cluster_assignments = self.mass_spectra_collection.mass_features_dataframe[["cluster"]].copy()
2364
2365            # Check if group exists and handle overwrite logic
2366            if group_name in hdf_handle:
2367                if not overwrite:
2368                    return
2369                del hdf_handle[group_name]
2370            
2371            grp = hdf_handle.create_group(group_name)
2372
2373            # Save the index, converting strings to bytes
2374            grp.create_dataset("index", data=cluster_assignments.index.astype(str).values.astype('S'))
2375            
2376            # Save the "cluster" column
2377            grp.create_dataset("cluster", data=cluster_assignments["cluster"].values)
2378    
2379    def _build_cluster_mf_map(self):
2380        """Build a mapping of which mass features should be saved for each sample.
2381        
2382        This uses the same logic as process_consensus_features to determine which
2383        mass features were loaded and should be updated in HDF5 files.
2384        
2385        Returns
2386        -------
2387        dict
2388            Dictionary mapping sample_id to list of tuples (mf_id, cluster_id).
2389            Only includes samples that have loaded representative features.
2390            Returns empty dict if no clusters exist.
2391        
2392        Notes
2393        -----
2394        This follows the DRY principle by using the same get_sample_mf_map_for_representatives
2395        method used by process_consensus_features and ReadSavedLCMSCollection.
2396        """
2397        # Check if clusters exist
2398        if "cluster" not in self.mass_spectra_collection.mass_features_dataframe.columns:
2399            return {}
2400        
2401        # Check if cluster_summary_dataframe exists (needed by get_sample_mf_map_for_representatives)
2402        if not hasattr(self.mass_spectra_collection, 'cluster_summary_dataframe') or \
2403           self.mass_spectra_collection.cluster_summary_dataframe is None:
2404            return {}
2405        
2406        # Use the same DRY helper method that process_consensus_features uses
2407        # This ensures consistency across the codebase
2408        cluster_mf_map = self.mass_spectra_collection.get_sample_mf_map_for_representatives(
2409            include_cluster_id=True
2410        )
2411        
2412        return cluster_mf_map
2413    
2414    def _update_raw_file_locations_in_hdf5(self):
2415        """Update raw file locations in each LCMS object's HDF5 file.
2416        
2417        This method updates the 'original_file_location' attribute in each LCMS object's
2418        HDF5 file to reflect the new raw file location after files have been relocated.
2419        """
2420        for lcms_obj in self.mass_spectra_collection:
2421            # Get the HDF5 file path for this LCMS object
2422            hdf5_path = lcms_obj.file_location.with_suffix('.hdf5')
2423            
2424            if hdf5_path.exists():
2425                with h5py.File(hdf5_path, 'a') as hdf_handle:
2426                    # Update the original_file_location attribute
2427                    if 'original_file_location' in hdf_handle.attrs:
2428                        hdf_handle.attrs['original_file_location'] = str(lcms_obj.raw_file_location)
2429                    # If the attribute does not exist, create it
2430                    else:
2431                        hdf_handle.attrs.create('original_file_location', str(lcms_obj.raw_file_location))
2432    
2433    def _save_induced_mass_features_to_hdf5(self, overwrite):
2434        """Save induced mass features to the collection HDF5 file.
2435        
2436        Induced mass features are gap-filled features that only exist at the collection level.
2437        They are saved with full detail (all attributes and datasets) in the collection HDF5 file
2438        and distributed to individual LCMS objects when the collection is loaded.
2439        
2440        The induced mass features are stored in the collection's induced_mass_features_dataframe
2441        and are regenerated as LCMSMassFeature objects for saving.
2442        
2443        Parameters
2444        ----------
2445        overwrite : bool
2446            If True, overwrites existing induced mass features group. If False, skips if group exists.
2447        """
2448        # Check if we have any induced mass features to save
2449        if (self.mass_spectra_collection.induced_mass_features_dataframe is None or 
2450            self.mass_spectra_collection.induced_mass_features_dataframe.empty):
2451            return
2452        
2453        # Open the collection HDF5 file to save induced mass features
2454        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2455            group_name = "induced_mass_features"
2456            
2457            # Check if group exists and handle overwrite logic
2458            if group_name in hdf_handle:
2459                if not overwrite:
2460                    return
2461                del hdf_handle[group_name]
2462            
2463            # Create top-level group for induced mass features
2464            imf_group = hdf_handle.create_group(group_name)
2465            
2466            # Get the induced mass features dataframe
2467            induced_df = self.mass_spectra_collection.induced_mass_features_dataframe
2468            
2469            # Get unique sample IDs from the dataframe
2470            sample_ids = induced_df['sample_id'].unique()
2471            
2472            # Iterate through each sample and save its induced mass features
2473            for sample_id in sample_ids:
2474                # Filter dataframe to this sample
2475                sample_df = induced_df[induced_df['sample_id'] == sample_id].copy()
2476                
2477                if sample_df.empty:
2478                    continue
2479                
2480                # Regenerate mass features from the dataframe
2481                regenerated_features = self._regenerate_mass_features_from_sample_df(
2482                    sample_df, sample_id
2483                )
2484                
2485                if not regenerated_features:
2486                    continue
2487                
2488                # Create a subgroup for this sample's induced mass features
2489                sample_group = imf_group.create_group(str(sample_id))
2490                
2491                # Use the static helper method from LCMSExport to save the mass features
2492                LCMSExport._save_mass_features_dict_to_hdf5(
2493                    regenerated_features, 
2494                    sample_group, 
2495                    overwrite=overwrite
2496                )
2497    
2498    def _save_induced_eics_to_hdf5(self, overwrite):
2499        """Save EICs for induced mass features to the collection HDF5 file.
2500        
2501        Induced mass features are gap-filled features created during process_consensus_features.
2502        Their associated EICs need to be saved at the collection level so they can be reloaded.
2503        
2504        The induced mass features are identified from the collection's induced_mass_features_dataframe,
2505        and their EICs are retrieved from the individual LCMS objects.
2506        
2507        Parameters
2508        ----------
2509        overwrite : bool
2510            If True, overwrites existing induced EICs group. If False, skips if group exists.
2511        """
2512        # Check if we have any induced mass features to save
2513        if (self.mass_spectra_collection.induced_mass_features_dataframe is None or 
2514            self.mass_spectra_collection.induced_mass_features_dataframe.empty):
2515            return
2516        
2517        # Open the collection HDF5 file to save induced EICs
2518        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2519            group_name = "induced_eics"
2520            
2521            # Check if group exists and handle overwrite logic
2522            if group_name in hdf_handle:
2523                if not overwrite:
2524                    return
2525                del hdf_handle[group_name]
2526            
2527            # Create top-level group for induced EICs
2528            induced_eics_group = hdf_handle.create_group(group_name)
2529            
2530            # Get the induced mass features dataframe
2531            induced_df = self.mass_spectra_collection.induced_mass_features_dataframe
2532            
2533            # Get unique sample IDs from the dataframe
2534            sample_ids = induced_df['sample_id'].unique()
2535            
2536            # Iterate through each sample and save EICs for its induced mass features
2537            for sample_id in sample_ids:
2538                lcms_obj = self.mass_spectra_collection[sample_id]
2539                
2540                # Filter dataframe to this sample
2541                sample_df = induced_df[induced_df['sample_id'] == sample_id].copy()
2542                
2543                if sample_df.empty:
2544                    continue
2545                
2546                # Collect EICs for induced mass features using _eic_mz from dataframe
2547                induced_eics = {}
2548                for _, row in sample_df.iterrows():
2549                    # Get the EIC m/z from the dataframe
2550                    eic_mz = row.get('_eic_mz')
2551                    
2552                    if eic_mz is not None and pd.notna(eic_mz):
2553                        # Try to get the EIC from the LCMS object
2554                        if hasattr(lcms_obj, 'eics') and lcms_obj.eics and eic_mz in lcms_obj.eics:
2555                            induced_eics[eic_mz] = lcms_obj.eics[eic_mz]
2556                
2557                if not induced_eics:
2558                    continue
2559                
2560                # Create a subgroup for this sample's induced EICs
2561                sample_group = induced_eics_group.create_group(str(sample_id))
2562                
2563                # Use the static helper method from LCMSExport to save the EICs
2564                LCMSExport._save_eics_dict_to_hdf5(induced_eics, sample_group, overwrite)
2565    
2566    def _regenerate_mass_features_from_sample_df(self, sample_df, sample_id):
2567        """Regenerate induced mass features from a sample-specific dataframe.
2568        
2569        This method creates LCMSMassFeature objects from rows in the induced_mass_features_dataframe
2570        for a specific sample. The regenerated features are used for saving to HDF5.
2571        
2572        Parameters
2573        ----------
2574        sample_df : pd.DataFrame
2575            DataFrame containing induced mass features for a specific sample.
2576        sample_id : int
2577            The sample ID (index in the collection).
2578            
2579        Returns
2580        -------
2581        dict
2582            Dictionary of regenerated LCMSMassFeature objects keyed by feature ID.
2583        """
2584        from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature
2585        
2586        if sample_df.empty:
2587            return {}
2588        
2589        # Get the corresponding LCMS object for proper parent reference
2590        lcms_obj = self.mass_spectra_collection[sample_id]
2591        
2592        # Regenerate mass features from the dataframe
2593        regenerated_features = {}
2594        
2595        for _, row in sample_df.iterrows():
2596            # Extract the original ID from mf_id (format: c{cluster}_{index}_i)
2597            # This is the ID used in lcms_obj.induced_mass_features dict
2598            original_id = row['mf_id']
2599            
2600            # Create a new LCMSMassFeature with proper parent reference
2601            # Note: dataframe uses 'scan_time' but __init__ parameter is 'retention_time'
2602            mass_feature = LCMSMassFeature(
2603                lcms_parent=lcms_obj,
2604                mz=row['mz'],
2605                retention_time=row['scan_time'],  # Column is 'scan_time' in dataframe
2606                intensity=row['intensity'],
2607                apex_scan=int(row['apex_scan']),
2608                persistence=row.get('persistence', None) if 'persistence' in row else None,
2609                id=original_id  # Use the original string ID from gap-filling
2610            )
2611            
2612            # Set additional attributes dynamically from dataframe columns
2613            # Skip columns already handled in __init__ or structural metadata
2614            skip_cols = {
2615                'sample_id', 'mf_id', 'mz', 'scan_time', 'scan_time_aligned',
2616                'intensity', 'apex_scan', 'persistence'}
2617            
2618            # Iterate through all columns and set via property setters
2619            for col_name in row.index:
2620                if col_name in skip_cols:
2621                    continue
2622                value = row[col_name]
2623                try:
2624                    if pd.isna(value):
2625                        continue
2626                except (TypeError, ValueError):
2627                    pass  # value is array-like; not NA, proceed
2628                
2629                # Set via property (public interface handles private attributes)
2630                # Don't save empty lists
2631                if isinstance(value, list) and len(value) == 0:
2632                    continue
2633                try:
2634                    setattr(mass_feature, col_name, value)
2635                except (AttributeError, TypeError):
2636                    pass  # Skip attributes that don't exist or can't be set
2637            
2638            # Set cluster_index if present
2639            if 'cluster' in row and pd.notna(row['cluster']):
2640                mass_feature.cluster_index = int(row['cluster'])
2641            
2642            regenerated_features[mass_feature.id] = mass_feature
2643        
2644        return regenerated_features
2645    
2646    def _save_lcms_objects_to_hdf5(self, cluster_mf_map, overwrite):
2647        """Save updated mass features for each LCMS object.
2648        
2649        This method implements a "selective update" strategy for mass features:
2650        - For mass features specified in cluster_mf_map (loaded representatives), we selectively
2651          update them by deleting their old entries and re-saving with new attributes.
2652        - Non-cluster features (not loaded) are never touched/overwritten.
2653        
2654        Note: EICs are NOT saved here. Induced feature EICs are saved at the collection level.
2655        
2656        Parameters
2657        ----------
2658        cluster_mf_map : dict
2659            Dictionary mapping sample_id to list of tuples (mf_id, cluster_id).
2660            This explicitly defines which mass features should be updated.
2661        overwrite : bool
2662            If True, allows overwriting of existing data. If False, skips if data exists.
2663        """
2664        for sample_id, lcms_obj in enumerate(self.mass_spectra_collection):
2665            hdf5_path = lcms_obj.file_location.with_suffix('.hdf5')
2666            
2667            if not hdf5_path.exists():
2668                # If HDF5 doesn't exist, we can't do selective update, raise error
2669                raise FileNotFoundError(
2670                    f"HDF5 file for LCMS object {lcms_obj.sample_name} not found at {hdf5_path}"
2671                )
2672            
2673            # Check if this sample has any loaded features in the map
2674            if sample_id not in cluster_mf_map or not cluster_mf_map[sample_id]:
2675                # Nothing loaded for this sample, nothing to update
2676                continue
2677            
2678            # Extract mf_ids from the map (cluster_mf_map contains tuples of (mf_id, cluster_id))
2679            mf_ids_to_update = [mf_id for mf_id, cluster_id in cluster_mf_map[sample_id]]
2680            
2681            # Perform selective update of mass features
2682            self._selective_update_mass_features(lcms_obj, hdf5_path, mf_ids_to_update, overwrite)
2683            
2684            # Save any new mass spectra that were added during processing
2685            self._save_new_mass_spectra(lcms_obj, hdf5_path, overwrite)
2686    
2687    def _save_new_mass_spectra(self, lcms_obj, hdf5_path, overwrite):
2688        """Save new mass spectra that were added during processing.
2689        
2690        This method checks what mass spectra are in lcms_obj._ms and saves any
2691        that aren't already in the HDF5 file's mass_spectra group. Uses the
2692        existing add_mass_spectrum_to_hdf5 method for consistency with original
2693        export logic.
2694        
2695        Parameters
2696        ----------
2697        lcms_obj : LCMSBase
2698            The LCMS object with potentially new mass spectra.
2699        hdf5_path : Path
2700            Path to the HDF5 file.
2701        overwrite : bool
2702            If True, allows overwriting existing spectra.
2703        """
2704        # Check if there are any mass spectra to save
2705        if not hasattr(lcms_obj, '_ms') or not lcms_obj._ms:
2706            return
2707        
2708        # Create an LCMS exporter instance for this LCMS object
2709        # This gives us access to add_mass_spectrum_to_hdf5 method inherited from HighResMassSpecExport
2710        # Turn hdf5_path into str without suffix for LCMSExport
2711        hdf5_path_str = str(hdf5_path.with_suffix(''))
2712        exporter = LCMSExport(
2713            out_file_path=hdf5_path_str,
2714            mass_spectra=lcms_obj
2715        )
2716        
2717        # Open HDF5 file and check existing mass spectra
2718        with h5py.File(hdf5_path, 'a') as hdf_handle:
2719            # Create mass_spectra group if it doesn't exist
2720            if 'mass_spectra' not in hdf_handle:
2721                ms_group = hdf_handle.create_group('mass_spectra')
2722                existing_scan_numbers = set()
2723            else:
2724                ms_group = hdf_handle['mass_spectra']
2725                existing_scan_numbers = set(int(k) for k in ms_group.keys())
2726            
2727            # Find new mass spectra (in _ms but not in HDF5)
2728            new_scan_numbers = set(lcms_obj._ms.keys()) - existing_scan_numbers
2729                        
2730            if not new_scan_numbers:
2731                return
2732            
2733            # Save new mass spectra using existing add_mass_spectrum_to_hdf5 method
2734            export_profile = lcms_obj.parameters.lc_ms.export_profile_spectra
2735            for scan_number in new_scan_numbers:
2736                mass_spec = lcms_obj._ms[scan_number]
2737                scan_group_name = str(scan_number)
2738                
2739                # Delete existing group if overwrite is True
2740                if scan_group_name in ms_group and overwrite:
2741                    del ms_group[scan_group_name]
2742                elif scan_group_name in ms_group:
2743                    continue
2744                
2745                # Use the existing method from HighResMassSpecExport
2746                exporter.add_mass_spectrum_to_hdf5(
2747                    hdf_handle=hdf_handle,
2748                    mass_spectrum=mass_spec,
2749                    group_key=scan_group_name,
2750                    mass_spectra_group=ms_group,
2751                    export_raw=export_profile
2752                )
2753    
2754    def _selective_update_mass_features(self, lcms_obj, hdf5_path, mf_ids_to_update, overwrite):
2755        """Selectively update mass features in HDF5 file.
2756        
2757        This method deletes only the mass features specified in mf_ids_to_update,
2758        then re-saves them with their potentially updated attributes. Non-cluster features
2759        in the HDF5 file are left untouched.
2760        
2761        Parameters
2762        ----------
2763        lcms_obj : LCMSBase
2764            The LCMS object with mass features to update.
2765        hdf5_path : Path
2766            Path to the HDF5 file.
2767        mf_ids_to_update : list of int
2768            List of mass feature IDs that should be updated. This explicitly defines
2769            which features were loaded and should be saved.
2770        overwrite : bool
2771            If True, allows overwriting. If False, skips if group exists.
2772        """
2773        if not mf_ids_to_update:
2774            return
2775        
2776        # Open HDF5 file and delete specified feature IDs, then re-save
2777        with h5py.File(hdf5_path, 'a') as hdf_handle:
2778            if 'mass_features' not in hdf_handle:
2779                return
2780            
2781            mf_group = hdf_handle['mass_features']
2782            
2783            # Delete features that are being updated
2784            for feature_id in mf_ids_to_update:
2785                feature_id_str = str(feature_id)
2786                if feature_id_str in mf_group:
2787                    del mf_group[feature_id_str]
2788            
2789            # Re-save updated features (only those that exist in mass_features dict)
2790            updated_features = {
2791                mf.id: mf for mf in lcms_obj.mass_features.values()
2792                if mf.id in mf_ids_to_update
2793            }
2794            
2795            if updated_features:
2796                LCMSExport._save_mass_features_dict_to_hdf5(
2797                    updated_features,
2798                    mf_group,
2799                    overwrite=overwrite
2800                )

A class to export an LCMS collection to HDF5 format.

This class provides methods to export collection-level data from multi-sample LC-MS experiments to HDF5 files. It handles the export of metadata, retention time alignments, cluster assignments, and induced mass features (gap-filled features) across the collection.

The exporter is designed to work with LCMSCollection objects and complements the individual LCMSExport class by focusing on collection-wide data rather than individual sample data.

Parameters
  • out_file_path (str | Path): The output file path, do not include the file extension. The .hdf5 extension will be added automatically.
  • mass_spectra_collection (LCMSCollection): The LCMS collection object containing multiple LCMS samples with processed mass features, alignments, and clustering information.
Attributes
  • out_file_path (Path): The output file path as a Path object.
  • mass_spectra_collection (LCMSCollection): The LCMS collection object to be exported.
Methods

export_to_hdf5(overwrite=False) Export the LCMS collection to an HDF5 file with collection-level data.

Notes

This class exports collection-level data including:

  • Sample manifest (metadata about all samples in the collection)
  • Retention time alignment data (if RT alignment has been performed)
  • Cluster assignments (consensus mass feature groupings across samples)
  • Induced mass features (gap-filled features saved to individual LCMS object HDF5 files)

Individual sample data (mass spectra, mass features, EICs, etc.) should be exported separately using the LCMSExport class for each LCMS object in the collection.

Examples

Export a collection after clustering and gap-filling:

>>> from corems.mass_spectra.output.export import LCMSCollectionExporter
>>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
>>> exporter.export_to_hdf5(overwrite=True)

The resulting HDF5 file will contain collection-level metadata and can be used to reconstruct the collection state for further analysis.

See Also

LCMSExport: Export individual LCMS objects to HDF5
LCMSCollection: The collection object being exported

LCMSCollectionExport(out_file_path, mass_spectra_collection)
2199    def __init__(self, out_file_path, mass_spectra_collection):
2200        self.out_file_path = Path(out_file_path)
2201        self.mass_spectra_collection = mass_spectra_collection
out_file_path
mass_spectra_collection
def export_to_hdf5( self, overwrite=False, save_parameters=True, parameter_format='toml', update_lcms_objects=True):
2203    def export_to_hdf5(
2204            self, 
2205            overwrite = False, 
2206            save_parameters=True, 
2207            parameter_format="toml",
2208            update_lcms_objects=True):
2209        """Export the LCMS collection to an HDF5 file.
2210        
2211        This method saves the collection-level data to an HDF5 file, including:
2212        - Basic metadata (date, folder location, gap-filling status)
2213        - Sample manifest
2214        - Retention time alignments (if available)
2215        - Cluster assignments (if available)
2216        - Induced mass features for each LCMS object (if gap-filling was performed)
2217        
2218        Individual LCMS objects in the collection are not exported by this method.
2219        Use LCMSExport for exporting individual LCMS objects.
2220        
2221        Parameters
2222        ----------
2223        overwrite : bool, optional
2224            If True, overwrites the output file if it already exists and replaces
2225            existing groups within the HDF5 file. If False, appends new data to
2226            existing file without overwriting existing groups. Default is False.
2227        save_parameters : bool, optional
2228            If True, saves the collection-level parameters to a separate file in the specified format.
2229            Default is True.
2230        parameter_format : str, optional
2231            The format for saving parameters, either "json" or "toml". Default is "toml".
2232        update_lcms_objects : bool, optional
2233            If True, updates the individual LCMS object HDF5 files with new raw file locations and any additional 
2234            information produced during the processing of the collection (e.g. cluster mass feature associations). Default is True.
2235            
2236        Notes
2237        -----
2238        The HDF5 file structure includes:
2239        - Attributes: date_utc, lcms_objects_folder, missing_mass_features_searched, manifest
2240        - Groups: rt_alignments, cluster_assignments (if available)
2241        
2242        Induced mass features are saved to the individual LCMS object HDF5 files
2243        within the .corems folder structure, not in the collection-level HDF5 file.
2244        
2245        Examples
2246        --------
2247        >>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
2248        >>> exporter.export_to_hdf5(overwrite=True)
2249        """
2250        if overwrite:
2251            if self.out_file_path.with_suffix(".hdf5").exists():
2252                self.out_file_path.with_suffix(".hdf5").unlink()
2253
2254        with h5py.File(self.out_file_path.with_suffix(".hdf5"), "a") as hdf_handle:
2255            # Add basic attributes to the HDF5 file, always overwrite these
2256            timenow = str(
2257                    datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M:%S %Z")
2258                )
2259            hdf_handle.attrs["date_utc"] = timenow
2260            hdf_handle.attrs["lcms_objects_folder"] = str(self.mass_spectra_collection.collection_parser.folder_location)
2261            hdf_handle.attrs["missing_mass_features_searched"] = self.mass_spectra_collection.missing_mass_features_searched
2262            hdf_handle.attrs["rt_aligned"] = self.mass_spectra_collection.rt_aligned
2263            hdf_handle.attrs["rt_alignment_attempted"] = self.mass_spectra_collection.rt_alignment_attempted
2264
2265            # Add the manifest to the HDF5 file, always overwrite this
2266            hdf_handle.attrs["manifest"] = self._convert_manifest_to_json()
2267
2268            # Save retention time alignments if they exist, only overwrite if specified
2269            self._save_rt_alignments_to_hdf5(hdf_handle, overwrite)
2270
2271            # Save cluster assignments if they exist, only overwrite if specified
2272            self._save_cluster_assignments_to_hdf5(hdf_handle, overwrite)
2273
2274        # Save new raw file locations to each LCMS object's HDF5 file if needed
2275        if hasattr(self.mass_spectra_collection, 'raw_files_relocated') and self.mass_spectra_collection.raw_files_relocated:
2276            self._update_raw_file_locations_in_hdf5()
2277
2278        # Save induced mass features to the collection with associations to each individual, only if lcms_collection.missing_mass_features_searched is True
2279        if self.mass_spectra_collection.missing_mass_features_searched:
2280            self._save_induced_mass_features_to_hdf5(overwrite)
2281            # Save EICs for induced mass features at collection level
2282            self._save_induced_eics_to_hdf5(overwrite)
2283        
2284        # Build cluster mass feature map to know which features to update
2285        # This uses the same logic as process_consensus_features to determine loaded features
2286        cluster_mf_map = self._build_cluster_mf_map()
2287        
2288        # Save updated mass features for each LCMS object
2289        # This implements selective update: only loaded features are updated, non-cluster features are preserved
2290        if update_lcms_objects:
2291            self._save_lcms_objects_to_hdf5(cluster_mf_map, overwrite)
2292
2293        # Save collection-level parameters as separate file
2294        if save_parameters:
2295            # Check if parameter_format is valid
2296            if parameter_format not in ["json", "toml"]:
2297                raise ValueError("parameter_format must be 'json' or 'toml'")
2298
2299            if parameter_format == "json":
2300                dump_lcms_collection_settings_json(
2301                    filename=self.out_file_path.with_suffix(".json"),
2302                    lcms_collection=self.mass_spectra_collection,
2303                )
2304            elif parameter_format == "toml":
2305                dump_lcms_collection_settings_toml(
2306                    filename=self.out_file_path.with_suffix(".toml"),
2307                    lcms_collection=self.mass_spectra_collection,
2308                )

Export the LCMS collection to an HDF5 file.

This method saves the collection-level data to an HDF5 file, including:

  • Basic metadata (date, folder location, gap-filling status)
  • Sample manifest
  • Retention time alignments (if available)
  • Cluster assignments (if available)
  • Induced mass features for each LCMS object (if gap-filling was performed)

Individual LCMS objects in the collection are not exported by this method. Use LCMSExport for exporting individual LCMS objects.

Parameters
  • overwrite (bool, optional): If True, overwrites the output file if it already exists and replaces existing groups within the HDF5 file. If False, appends new data to existing file without overwriting existing groups. Default is False.
  • save_parameters (bool, optional): If True, saves the collection-level parameters to a separate file in the specified format. Default is True.
  • parameter_format (str, optional): The format for saving parameters, either "json" or "toml". Default is "toml".
  • update_lcms_objects (bool, optional): If True, updates the individual LCMS object HDF5 files with new raw file locations and any additional information produced during the processing of the collection (e.g. cluster mass feature associations). Default is True.
Notes

The HDF5 file structure includes:

  • Attributes: date_utc, lcms_objects_folder, missing_mass_features_searched, manifest
  • Groups: rt_alignments, cluster_assignments (if available)

Induced mass features are saved to the individual LCMS object HDF5 files within the .corems folder structure, not in the collection-level HDF5 file.

Examples
>>> exporter = LCMSCollectionExporter("my_collection", lcms_collection)
>>> exporter.export_to_hdf5(overwrite=True)