From bc170bb0eeb35afe67f8814ccc720949f757694e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 08:59:32 +0000 Subject: [PATCH 01/27] Initial plan From 02ba4f7f8cd0ea0bd0e38c8dadcb91a5515bba97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:05:06 +0000 Subject: [PATCH 02/27] Add abundance-profile dendrogram refinement for metabolite grouping Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/f295dd07-5508-4ffe-b0d0-8120055a274c Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/bracketResults.py | 20 ++++++ src/metaboliteGrouping.py | 100 ++++++++++++++++++++++++++++++ tests/test_metabolite_grouping.py | 35 +++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/metaboliteGrouping.py create mode 100644 tests/test_metabolite_grouping.py diff --git a/src/bracketResults.py b/src/bracketResults.py index df23fe6..d1724b2 100644 --- a/src/bracketResults.py +++ b/src/bracketResults.py @@ -22,6 +22,7 @@ from .Chromatogram import Chromatogram from .MZHCA import HierarchicalClustering, cutTreeSized from .PolarsDB import PolarsDB +from .metaboliteGrouping import split_group_by_relative_abundance from .utils import ChromPeakPair from .utils import ( Bunch, @@ -1448,6 +1449,25 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): done = done + 1 + logging.info("Refining feature groups by abundance profile similarity") + abundance_cols = [col for col in table_df.columns if col.endswith("_Abundance_N")] + abundance_vectors = {} + if len(abundance_cols) > 0: + for row in table_df.select(["Num"] + abundance_cols).iter_rows(named=True): + abundance_vectors[row["Num"]] = [row.get(col) for col in abundance_cols] + + refined_groups = [] + for tGroup in groups: + refined_groups.extend( + split_group_by_relative_abundance( + tGroup, + abundance_vectors, + min_peak_correlation=minPeakCorrelation, + min_connection_rate=minConnectionRate, + ) + ) + groups = refined_groups + # Separate feature pair clusters; softer curGroup = 1 for tGroup in groups: diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py new file mode 100644 index 0000000..d53db90 --- /dev/null +++ b/src/metaboliteGrouping.py @@ -0,0 +1,100 @@ +import numpy as np +from . import HCA_general + + +def split_group_with_hca(group_ids, similarities, min_peak_correlation, min_connection_rate): + if len(group_ids) <= 2: + return [group_ids] + + data = [] + for feature_a in group_ids: + row = [] + for feature_b in group_ids: + if feature_a in similarities.keys() and feature_b in similarities[feature_a].keys(): + row.append(similarities[feature_a][feature_b]) + elif feature_a == feature_b: + row.append(1.0) + else: + row.append(0.0) + data.append(row) + + hca = HCA_general.HCA_generic() + tree = hca.generateTree(objs=data, ids=group_ids) + + def check_sub_cluster(tree, hca, corr_threshold, cut_off_min_ratio): + if isinstance(tree, HCA_general.HCALeaf): + return False + elif isinstance(tree, HCA_general.HCAComposite): + corrs = hca.getLinkFor(tree) + inds = hca.getIndsFor(tree) + if len(inds) == 0: + return False + + above_threshold = sum([corr > corr_threshold for i, corr in enumerate(corrs) if i in inds]) + return not (above_threshold * 1.0 / len(inds)) >= cut_off_min_ratio + else: + raise RuntimeError("Unknown if-branch") + + sub_clusters = hca.splitTreeWithCallback(tree, lambda cluster, hca: check_sub_cluster(cluster, hca, min_peak_correlation, min_connection_rate), recursive=True) + return [[leaf.getID() for leaf in sub_cluster.getLeaves()] for sub_cluster in sub_clusters] + + +def _normalize_relative_abundance_profile(values): + profile = [] + for value in values: + try: + numeric = float(value) + if np.isnan(numeric): + numeric = 0.0 + except Exception: + numeric = 0.0 + profile.append(max(0.0, numeric)) + + total = sum(profile) + if total > 0: + profile = [value / total for value in profile] + return profile + + +def _profile_correlation(profile_a, profile_b): + if profile_a is None or profile_b is None: + return 0.0 + if len(profile_a) < 2 or len(profile_b) < 2 or len(profile_a) != len(profile_b): + return 0.0 + if np.std(profile_a) == 0 or np.std(profile_b) == 0: + return 0.0 + + corr = np.corrcoef(profile_a, profile_b)[0, 1] + if np.isnan(corr): + return 0.0 + return float(corr) + + +def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_correlation, min_connection_rate): + if len(group_ids) <= 2: + return [group_ids] + + profiles = {} + for feature_id in group_ids: + if feature_id in abundance_vectors: + profiles[feature_id] = _normalize_relative_abundance_profile(abundance_vectors[feature_id]) + + if len(profiles) < len(group_ids): + return [group_ids] + if len(set([len(profile) for profile in profiles.values()])) != 1: + return [group_ids] + + similarities = {feature_id: {} for feature_id in group_ids} + for i in range(len(group_ids)): + for j in range(i + 1, len(group_ids)): + feature_a = group_ids[i] + feature_b = group_ids[j] + similarities[feature_a][feature_b] = _profile_correlation(profiles[feature_a], profiles[feature_b]) + similarities[feature_b][feature_a] = similarities[feature_a][feature_b] + + return split_group_with_hca( + group_ids, + similarities, + min_peak_correlation=min_peak_correlation, + min_connection_rate=min_connection_rate, + ) diff --git a/tests/test_metabolite_grouping.py b/tests/test_metabolite_grouping.py new file mode 100644 index 0000000..fe56152 --- /dev/null +++ b/tests/test_metabolite_grouping.py @@ -0,0 +1,35 @@ +from src.metaboliteGrouping import split_group_by_relative_abundance + + +def _normalize_groups(groups): + return sorted([sorted(group) for group in groups]) + + +def test_split_group_by_relative_abundance_splits_dissimilar_profiles(): + groups = split_group_by_relative_abundance( + [1, 2, 3, 4], + { + 1: [100, 200, 300], + 2: [50, 100, 150], + 3: [300, 200, 100], + 4: [150, 100, 50], + }, + min_peak_correlation=0.8, + min_connection_rate=0.6, + ) + + assert _normalize_groups(groups) == [[1, 2], [3, 4]] + + +def test_split_group_by_relative_abundance_keeps_group_for_missing_vectors(): + groups = split_group_by_relative_abundance( + [10, 11, 12], + { + 10: [100, 200, 300], + 11: [120, 220, 320], + }, + min_peak_correlation=0.8, + min_connection_rate=0.6, + ) + + assert groups == [[10, 11, 12]] From b4775a9e73d7b00c1018464f58d6e4c0b0d566b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:06:41 +0000 Subject: [PATCH 03/27] Address validation feedback in abundance grouping helper Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/f295dd07-5508-4ffe-b0d0-8120055a274c Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/metaboliteGrouping.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index d53db90..1f5669c 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -29,8 +29,9 @@ def check_sub_cluster(tree, hca, corr_threshold, cut_off_min_ratio): inds = hca.getIndsFor(tree) if len(inds) == 0: return False + inds_set = set(inds) - above_threshold = sum([corr > corr_threshold for i, corr in enumerate(corrs) if i in inds]) + above_threshold = sum([corr > corr_threshold for i, corr in enumerate(corrs) if i in inds_set]) return not (above_threshold * 1.0 / len(inds)) >= cut_off_min_ratio else: raise RuntimeError("Unknown if-branch") @@ -81,7 +82,8 @@ def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_cor if len(profiles) < len(group_ids): return [group_ids] - if len(set([len(profile) for profile in profiles.values()])) != 1: + profile_lengths = {len(profile) for profile in profiles.values()} + if len(profile_lengths) != 1: return [group_ids] similarities = {feature_id: {} for feature_id in group_ids} From f4c00f63da4c9d05bc70cb82d8778e3da5419911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:08:22 +0000 Subject: [PATCH 04/27] Polish abundance grouping helper robustness and clarity Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/f295dd07-5508-4ffe-b0d0-8120055a274c Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/bracketResults.py | 1 + src/metaboliteGrouping.py | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/bracketResults.py b/src/bracketResults.py index d1724b2..f4328c1 100644 --- a/src/bracketResults.py +++ b/src/bracketResults.py @@ -1450,6 +1450,7 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): done = done + 1 logging.info("Refining feature groups by abundance profile similarity") + # use per-file native peak abundances to compare feature profile similarity across samples abundance_cols = [col for col in table_df.columns if col.endswith("_Abundance_N")] abundance_vectors = {} if len(abundance_cols) > 0: diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 1f5669c..07b1f01 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -1,8 +1,9 @@ import numpy as np -from . import HCA_general def split_group_with_hca(group_ids, similarities, min_peak_correlation, min_connection_rate): + from . import HCA_general + if len(group_ids) <= 2: return [group_ids] @@ -34,7 +35,7 @@ def check_sub_cluster(tree, hca, corr_threshold, cut_off_min_ratio): above_threshold = sum([corr > corr_threshold for i, corr in enumerate(corrs) if i in inds_set]) return not (above_threshold * 1.0 / len(inds)) >= cut_off_min_ratio else: - raise RuntimeError("Unknown if-branch") + raise RuntimeError(f"Unexpected tree node type: {type(tree).__name__}. Expected HCALeaf or HCAComposite") sub_clusters = hca.splitTreeWithCallback(tree, lambda cluster, hca: check_sub_cluster(cluster, hca, min_peak_correlation, min_connection_rate), recursive=True) return [[leaf.getID() for leaf in sub_cluster.getLeaves()] for sub_cluster in sub_clusters] @@ -47,7 +48,7 @@ def _normalize_relative_abundance_profile(values): numeric = float(value) if np.isnan(numeric): numeric = 0.0 - except Exception: + except (TypeError, ValueError): numeric = 0.0 profile.append(max(0.0, numeric)) From 2c51ed5830e102c679ef94275a1d4192f37812e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:09:54 +0000 Subject: [PATCH 05/27] Apply minor cleanup from review feedback Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/f295dd07-5508-4ffe-b0d0-8120055a274c Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/metaboliteGrouping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 07b1f01..8a716cf 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -11,7 +11,7 @@ def split_group_with_hca(group_ids, similarities, min_peak_correlation, min_conn for feature_a in group_ids: row = [] for feature_b in group_ids: - if feature_a in similarities.keys() and feature_b in similarities[feature_a].keys(): + if feature_a in similarities and feature_b in similarities[feature_a]: row.append(similarities[feature_a][feature_b]) elif feature_a == feature_b: row.append(1.0) @@ -32,7 +32,7 @@ def check_sub_cluster(tree, hca, corr_threshold, cut_off_min_ratio): return False inds_set = set(inds) - above_threshold = sum([corr > corr_threshold for i, corr in enumerate(corrs) if i in inds_set]) + above_threshold = sum(corr > corr_threshold for i, corr in enumerate(corrs) if i in inds_set) return not (above_threshold * 1.0 / len(inds)) >= cut_off_min_ratio else: raise RuntimeError(f"Unexpected tree node type: {type(tree).__name__}. Expected HCALeaf or HCAComposite") From e26d2f728bf08064e25b6b02a320dafa4cd9760f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:35:09 +0000 Subject: [PATCH 06/27] Add processing-tab controls for abundance similarity grouping Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/105314bd-9807-46c6-bacb-6ed553a75b97 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- ...sing-tab-abundance-similarity-controls.png | Bin 0 -> 12223 bytes src/MExtract.py | 11 ++++ src/bracketResults.py | 48 +++++++++++------- src/mePyGuis/guis/mainwindow.ui | 35 +++++++++++++ src/mePyGuis/mainWindow.py | 20 +++++++- tests/test_metabolite_grouping.py | 24 +++++++++ 6 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 docs/screenshots/processing-tab-abundance-similarity-controls.png diff --git a/docs/screenshots/processing-tab-abundance-similarity-controls.png b/docs/screenshots/processing-tab-abundance-similarity-controls.png new file mode 100644 index 0000000000000000000000000000000000000000..2cfd98bb6f97d64837ed45185f990776669136ab GIT binary patch literal 12223 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 literal 0 HcmV?d00001 diff --git a/src/MExtract.py b/src/MExtract.py index 5d86bf6..27478d0 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -2555,6 +2555,10 @@ def loadSettingsFile(self, settingsFile, checkExperimentType=True, silent=False) self.ui.useSILRatioForConvolution.setChecked(self.to_bool(sett.value("useSILRatioForConvolution"))) if sett.contains("minConnectionRate"): self.ui.minConnectionRate.setValue(self.to_double(sett.value("minConnectionRate")) * 100.0) + if sett.contains("useAbundanceSimilarityForConvolution"): + self.ui.useAbundanceSimilarityForConvolution.setChecked(self.to_bool(sett.value("useAbundanceSimilarityForConvolution"))) + if sett.contains("abundanceSimilarityThreshold"): + self.ui.abundanceSimilarityThreshold.setValue(self.to_double(sett.value("abundanceSimilarityThreshold")) * 100.0) if sett.contains("GroupIntegrateMissedPeaks"): self.ui.integratedMissedPeaks.setChecked(self.to_bool(sett.value("GroupIntegrateMissedPeaks"))) @@ -2780,6 +2784,11 @@ def saveSettingsFile(self, settingsFile, clear=True): self.ui.useSILRatioForConvolution.checkState() == QtCore.Qt.Checked, ) sett.setValue("minConnectionRate", self.ui.minConnectionRate.value() / 100.0) + sett.setValue( + "useAbundanceSimilarityForConvolution", + self.ui.useAbundanceSimilarityForConvolution.checkState() == QtCore.Qt.Checked, + ) + sett.setValue("abundanceSimilarityThreshold", self.ui.abundanceSimilarityThreshold.value() / 100.0) sett.setValue("GroupIntegrateMissedPeaks", self.ui.integratedMissedPeaks.isChecked()) sett.setValue( @@ -3670,6 +3679,8 @@ def runProcess(self, dontSave=False, askStarting=True): minConnectionsInFiles=self.ui.metaboliteClusterMinConnections.value(), minConnectionRate=self.ui.minConnectionRate.value() / 100.0, minPeakCorrelation=self.ui.minCorrelation.value() / 100.0, + useAbundanceSimilarity=self.ui.useAbundanceSimilarityForConvolution.checkState() == QtCore.Qt.Checked, + abundanceSimilarityThreshold=self.ui.abundanceSimilarityThreshold.value() / 100.0, useRatio=self.ui.useSILRatioForConvolution.checkState() == QtCore.Qt.Checked, cpus=min(len(files), cpus), ) diff --git a/src/bracketResults.py b/src/bracketResults.py index f4328c1..a4a7857 100644 --- a/src/bracketResults.py +++ b/src/bracketResults.py @@ -1160,6 +1160,8 @@ def calculateMetaboliteGroups( minConnectionsInFiles=1, minConnectionRate=0.4, minPeakCorrelation=0.85, + useAbundanceSimilarity=True, + abundanceSimilarityThreshold=None, useRatio=False, runIdentificationInstance=None, pwMaxSet=None, @@ -1449,25 +1451,27 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): done = done + 1 - logging.info("Refining feature groups by abundance profile similarity") - # use per-file native peak abundances to compare feature profile similarity across samples - abundance_cols = [col for col in table_df.columns if col.endswith("_Abundance_N")] - abundance_vectors = {} - if len(abundance_cols) > 0: - for row in table_df.select(["Num"] + abundance_cols).iter_rows(named=True): - abundance_vectors[row["Num"]] = [row.get(col) for col in abundance_cols] - - refined_groups = [] - for tGroup in groups: - refined_groups.extend( - split_group_by_relative_abundance( - tGroup, - abundance_vectors, - min_peak_correlation=minPeakCorrelation, - min_connection_rate=minConnectionRate, + if useAbundanceSimilarity: + logging.info("Refining feature groups by abundance profile similarity") + # use per-file native peak abundances to compare feature profile similarity across samples + abundance_cols = [col for col in table_df.columns if col.endswith("_Abundance_N")] + abundance_vectors = {} + if len(abundance_cols) > 0: + for row in table_df.select(["Num"] + abundance_cols).iter_rows(named=True): + abundance_vectors[row["Num"]] = [row.get(col) for col in abundance_cols] + + abundance_threshold = minPeakCorrelation if abundanceSimilarityThreshold is None else abundanceSimilarityThreshold + refined_groups = [] + for tGroup in groups: + refined_groups.extend( + split_group_by_relative_abundance( + tGroup, + abundance_vectors, + min_peak_correlation=abundance_threshold, + min_connection_rate=minConnectionRate, + ) ) - ) - groups = refined_groups + groups = refined_groups # Separate feature pair clusters; softer curGroup = 1 @@ -1545,6 +1549,14 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): resDB.conn.insert_row("Parameters", {"Parameter": "MEConvoluting_minConnectionsInFiles", "Value": f"{minConnectionsInFiles}"}) resDB.conn.insert_row("Parameters", {"Parameter": "MEConvoluting_minConnectionRate", "Value": f"{minConnectionRate}"}) resDB.conn.insert_row("Parameters", {"Parameter": "MEConvoluting_minPeakCorrelation", "Value": f"{minPeakCorrelation}"}) + resDB.conn.insert_row("Parameters", {"Parameter": "MEConvoluting_useAbundanceSimilarity", "Value": f"{useAbundanceSimilarity}"}) + resDB.conn.insert_row( + "Parameters", + { + "Parameter": "MEConvoluting_abundanceSimilarityThreshold", + "Value": f"{minPeakCorrelation if abundanceSimilarityThreshold is None else abundanceSimilarityThreshold}", + }, + ) resDB.conn.set_table(new_sheet_name, table_df) if double_peaks_df is not None and len(double_peaks_df) > 0: resDB.conn.insert_table(new_sheet_name + "_doublePeaks", double_peaks_df) diff --git a/src/mePyGuis/guis/mainwindow.ui b/src/mePyGuis/guis/mainwindow.ui index 4b047f2..08e3f7c 100644 --- a/src/mePyGuis/guis/mainwindow.ui +++ b/src/mePyGuis/guis/mainwindow.ui @@ -4343,6 +4343,39 @@ font: 7pt; + + + + Use abundance similarity + + + true + + + + + + + Abundance similarity threshold + + + + + + + + + + % + + + 100.000000000000000 + + + 85.000000000000000 + + + @@ -5926,6 +5959,8 @@ font: 7pt; polynomValue minConnectionRate metaboliteClusterMinConnections + useAbundanceSimilarityForConvolution + abundanceSimilarityThreshold integratedMissedPeaks integrationMaxTimeDifference reintegrateIntensityCutoff diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 6abb85b..32e1b18 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -1901,6 +1901,18 @@ def setupUi(self, MainWindow): self.useSILRatioForConvolution = QtWidgets.QCheckBox(self.convoluteResults) self.useSILRatioForConvolution.setObjectName(_fromUtf8("useSILRatioForConvolution")) self.horizontalLayout_8.addWidget(self.useSILRatioForConvolution) + self.useAbundanceSimilarityForConvolution = QtWidgets.QCheckBox(self.convoluteResults) + self.useAbundanceSimilarityForConvolution.setChecked(True) + self.useAbundanceSimilarityForConvolution.setObjectName(_fromUtf8("useAbundanceSimilarityForConvolution")) + self.horizontalLayout_8.addWidget(self.useAbundanceSimilarityForConvolution) + self.label_122 = QtWidgets.QLabel(self.convoluteResults) + self.label_122.setObjectName(_fromUtf8("label_122")) + self.horizontalLayout_8.addWidget(self.label_122) + self.abundanceSimilarityThreshold = QtWidgets.QDoubleSpinBox(self.convoluteResults) + self.abundanceSimilarityThreshold.setMaximum(100.0) + self.abundanceSimilarityThreshold.setProperty("value", 85.0) + self.abundanceSimilarityThreshold.setObjectName(_fromUtf8("abundanceSimilarityThreshold")) + self.horizontalLayout_8.addWidget(self.abundanceSimilarityThreshold) self.gridLayout_14.addLayout(self.horizontalLayout_8, 0, 0, 1, 1) self.verticalLayout_9.addWidget(self.convoluteResults) self.integratedMissedPeaks = QtWidgets.QGroupBox(self.frame_bracketResults) @@ -2791,7 +2803,9 @@ def setupUi(self, MainWindow): MainWindow.setTabOrder(self.alignChromatograms, self.polynomValue) MainWindow.setTabOrder(self.polynomValue, self.minConnectionRate) MainWindow.setTabOrder(self.minConnectionRate, self.metaboliteClusterMinConnections) - MainWindow.setTabOrder(self.metaboliteClusterMinConnections, self.integratedMissedPeaks) + MainWindow.setTabOrder(self.metaboliteClusterMinConnections, self.useAbundanceSimilarityForConvolution) + MainWindow.setTabOrder(self.useAbundanceSimilarityForConvolution, self.abundanceSimilarityThreshold) + MainWindow.setTabOrder(self.abundanceSimilarityThreshold, self.integratedMissedPeaks) MainWindow.setTabOrder(self.integratedMissedPeaks, self.integrationMaxTimeDifference) MainWindow.setTabOrder(self.integrationMaxTimeDifference, self.reintegrateIntensityCutoff) MainWindow.setTabOrder(self.reintegrateIntensityCutoff, self.groupsSelectFile) @@ -3165,6 +3179,10 @@ def retranslateUi(self, MainWindow): self.label_74.setText(_translate("MainWindow", "Minimum number of detections in files", None)) self.metaboliteClusterMinConnections.setPrefix(_translate("MainWindow", "≥ ", None)) self.useSILRatioForConvolution.setText(_translate("MainWindow", "Use SIL ratio", None)) + self.useAbundanceSimilarityForConvolution.setText(_translate("MainWindow", "Use abundance similarity", None)) + self.label_122.setText(_translate("MainWindow", "Abundance similarity threshold", None)) + self.abundanceSimilarityThreshold.setPrefix(_translate("MainWindow", "≥ ", None)) + self.abundanceSimilarityThreshold.setSuffix(_translate("MainWindow", " %", None)) self.integratedMissedPeaks.setTitle(_translate("MainWindow", "Integrate missing feature pairs", None)) self.label_55.setText(_translate("MainWindow", "Maximum time difference", None)) self.integrationMaxTimeDifference.setPrefix(_translate("MainWindow", "± ", None)) diff --git a/tests/test_metabolite_grouping.py b/tests/test_metabolite_grouping.py index fe56152..8f160ec 100644 --- a/tests/test_metabolite_grouping.py +++ b/tests/test_metabolite_grouping.py @@ -33,3 +33,27 @@ def test_split_group_by_relative_abundance_keeps_group_for_missing_vectors(): ) assert groups == [[10, 11, 12]] + + +def test_split_group_by_relative_abundance_respects_similarity_threshold(): + abundance_vectors = { + 1: [100, 200, 300, 400], + 2: [90, 210, 280, 420], + 3: [400, 300, 200, 100], + } + + relaxed_groups = split_group_by_relative_abundance( + [1, 2, 3], + abundance_vectors, + min_peak_correlation=-1.0, + min_connection_rate=0.6, + ) + strict_groups = split_group_by_relative_abundance( + [1, 2, 3], + abundance_vectors, + min_peak_correlation=0.8, + min_connection_rate=0.6, + ) + + assert _normalize_groups(relaxed_groups) == [[1, 2, 3]] + assert _normalize_groups(strict_groups) == [[1, 2], [3]] From 875b256302b70d26c058b9107d4b40c59392ebb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 09:36:41 +0000 Subject: [PATCH 07/27] Refine naming for abundance threshold in grouping flow Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/105314bd-9807-46c6-bacb-6ed553a75b97 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/bracketResults.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bracketResults.py b/src/bracketResults.py index a4a7857..28dfe17 100644 --- a/src/bracketResults.py +++ b/src/bracketResults.py @@ -1460,14 +1460,14 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): for row in table_df.select(["Num"] + abundance_cols).iter_rows(named=True): abundance_vectors[row["Num"]] = [row.get(col) for col in abundance_cols] - abundance_threshold = minPeakCorrelation if abundanceSimilarityThreshold is None else abundanceSimilarityThreshold + effective_abundance_threshold = minPeakCorrelation if abundanceSimilarityThreshold is None else abundanceSimilarityThreshold refined_groups = [] for tGroup in groups: refined_groups.extend( split_group_by_relative_abundance( tGroup, abundance_vectors, - min_peak_correlation=abundance_threshold, + min_peak_correlation=effective_abundance_threshold, min_connection_rate=minConnectionRate, ) ) From b800c82eae096daf3a955eb608bf5712b10c0c95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:02:26 +0000 Subject: [PATCH 08/27] Add abundance profiles tab in experiment results Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/0c40c85f-6bb6-4c4c-a7fd-34df71bbc343 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- ...eriment-results-abundance-profiles-tab.png | Bin 0 -> 16827 bytes src/MExtract.py | 180 +++++++++++++++++- src/mePyGuis/guis/mainwindow.ui | 82 ++++++++ src/mePyGuis/mainWindow.py | 45 +++++ 4 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 docs/screenshots/experiment-results-abundance-profiles-tab.png diff --git a/docs/screenshots/experiment-results-abundance-profiles-tab.png b/docs/screenshots/experiment-results-abundance-profiles-tab.png new file mode 100644 index 0000000000000000000000000000000000000000..9ab7b2daa672b3c706b00e2395253cf026287b56 GIT binary patch literal 16827 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 0: + group_changes.append(i - 0.5) + last_group = grp + + for feature_id in sorted(set(selected_ids)): + row = rows_by_num.get(feature_id) + if row is None: + continue + y_vals = [] + for grp, sample in sample_entries: + col = sample + abundance_suffix + val = row.get(col) if col in table_df.columns else None + if val in [None, ""]: + y_vals.append(float("nan")) + continue + try: + val = float(val) + except Exception: + y_vals.append(float("nan")) + continue + if log_scale and val <= 0: + y_vals.append(float("nan")) + else: + y_vals.append(val) + ax.plot(x, y_vals, marker="o", linewidth=1.2, label=f"Feature {feature_id}") + + for sep in group_changes: + ax.axvline(sep, color="lightgray", linestyle="--", linewidth=0.8) + ax.set_xticks(x) + ax.set_xticklabels(x_labels, rotation=45, ha="right") + ax.legend() + + if log_scale: + ax.set_yscale("log") + self.drawCanvas(self.ui.resultsExperimentAbundance_plot, showLegendOverwrite=False) + def _refreshExperimentEICs(self, *args): """Re-draw experiment EICs when separation/normalisation controls change, but only if raw data is already loaded.""" if hasattr(self, "loadedMZXMLs") and self.loadedMZXMLs is not None: self.resultsExperimentChanged() + def _refreshExperimentAbundancePlot(self, *args): + self.updateExperimentAbundancePlot(self._getSelectedExperimentPlotItems()) + # # @@ -11023,6 +11161,8 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal self.ui.resultsExperimentNormaliseXICs_checkBox.stateChanged.connect(self._refreshExperimentEICs) self.ui.resultsExperimentNormaliseXICsSeparately_checkBox.stateChanged.connect(self._refreshExperimentEICs) self.ui.showLegend_experiment.stateChanged.connect(self._refreshExperimentEICs) + self.ui.comboBox_abundancePlotType.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) + self.ui.comboBox_abundanceScale.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) self.ui.eicSmoothingWindow.currentIndexChanged.connect(self.smoothingWindowChanged) self.smoothingWindowChanged() @@ -11232,6 +11372,26 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal vbox.addWidget(self.ui.resultsExperimentMSScanPeaks_plot.canvas) self.ui.resultsExperimentMSScan_widget.setLayout(vbox) + # Setup experiment abundance plot + self.ui.resultsExperimentAbundance_plot = QtCore.QObject() + self.ui.resultsExperimentAbundance_plot.dpi = 50 + self.ui.resultsExperimentAbundance_plot.fig = Figure((5.0, 4.0), dpi=self.ui.resultsExperimentAbundance_plot.dpi, facecolor="white") + self.ui.resultsExperimentAbundance_plot.fig.subplots_adjust(left=0.08, bottom=0.15, right=0.99, top=0.95) + self.ui.resultsExperimentAbundance_plot.canvas = FigureCanvas(self.ui.resultsExperimentAbundance_plot.fig) + self.ui.resultsExperimentAbundance_plot.canvas.setParent(self.ui.resultsExperimentAbundance_widget) + self.ui.resultsExperimentAbundance_plot.axes = self.ui.resultsExperimentAbundance_plot.fig.add_subplot(111) + simpleaxis(self.ui.resultsExperimentAbundance_plot.axes) + self.ui.resultsExperimentAbundance_plot.twinxs = [self.ui.resultsExperimentAbundance_plot.axes] + self.ui.resultsExperimentAbundance_plot.mpl_toolbar = NavigationToolbar( + self.ui.resultsExperimentAbundance_plot.canvas, + self.ui.resultsExperimentAbundance_widget, + ) + + vbox = QtWidgets.QVBoxLayout() + vbox.addWidget(self.ui.resultsExperimentAbundance_plot.mpl_toolbar) + vbox.addWidget(self.ui.resultsExperimentAbundance_plot.canvas) + self.ui.resultsExperimentAbundance_widget.setLayout(vbox) + # Setup experiment MSMS plot - multiple MS/MS spectra subplots self.ui.plMSMS_exp = QtCore.QObject() self.ui.plMSMS_exp.dpi = 50 diff --git a/src/mePyGuis/guis/mainwindow.ui b/src/mePyGuis/guis/mainwindow.ui index 08e3f7c..83cc531 100644 --- a/src/mePyGuis/guis/mainwindow.ui +++ b/src/mePyGuis/guis/mainwindow.ui @@ -5775,6 +5775,88 @@ font: 7pt; + + + Abundance profiles + + + + + + + + Visualization + + + + + + + + Boxplot + + + + + Line plot + + + + + + + + Scale + + + + + + + + Linear + + + + + Logarithmic + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 0 + 0 + + + + + 700 + 500 + + + + + + diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 32e1b18..3615b8a 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -2640,6 +2640,41 @@ def setupUi(self, MainWindow): self.tableWidget_peakDetails_perGroup.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.gridLayout_peak_details.addWidget(self.splitter_peak_details, 0, 0, 1, 1) self.tabWidget_3.addTab(self.tab_peak_details, _fromUtf8("")) + self.tab_abundance_profiles = QtWidgets.QWidget() + self.tab_abundance_profiles.setObjectName(_fromUtf8("tab_abundance_profiles")) + self.gridLayout_abundance_profiles = QtWidgets.QGridLayout(self.tab_abundance_profiles) + self.gridLayout_abundance_profiles.setObjectName(_fromUtf8("gridLayout_abundance_profiles")) + self.horizontalLayout_abundance_controls = QtWidgets.QHBoxLayout() + self.horizontalLayout_abundance_controls.setObjectName(_fromUtf8("horizontalLayout_abundance_controls")) + self.label_abundance_visualization = QtWidgets.QLabel(self.tab_abundance_profiles) + self.label_abundance_visualization.setObjectName(_fromUtf8("label_abundance_visualization")) + self.horizontalLayout_abundance_controls.addWidget(self.label_abundance_visualization) + self.comboBox_abundancePlotType = QtWidgets.QComboBox(self.tab_abundance_profiles) + self.comboBox_abundancePlotType.setObjectName(_fromUtf8("comboBox_abundancePlotType")) + self.comboBox_abundancePlotType.addItem(_fromUtf8("")) + self.comboBox_abundancePlotType.addItem(_fromUtf8("")) + self.horizontalLayout_abundance_controls.addWidget(self.comboBox_abundancePlotType) + self.label_abundance_scale = QtWidgets.QLabel(self.tab_abundance_profiles) + self.label_abundance_scale.setObjectName(_fromUtf8("label_abundance_scale")) + self.horizontalLayout_abundance_controls.addWidget(self.label_abundance_scale) + self.comboBox_abundanceScale = QtWidgets.QComboBox(self.tab_abundance_profiles) + self.comboBox_abundanceScale.setObjectName(_fromUtf8("comboBox_abundanceScale")) + self.comboBox_abundanceScale.addItem(_fromUtf8("")) + self.comboBox_abundanceScale.addItem(_fromUtf8("")) + self.horizontalLayout_abundance_controls.addWidget(self.comboBox_abundanceScale) + spacerItem74 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_abundance_controls.addItem(spacerItem74) + self.gridLayout_abundance_profiles.addLayout(self.horizontalLayout_abundance_controls, 0, 0, 1, 1) + self.resultsExperimentAbundance_widget = QtWidgets.QWidget(self.tab_abundance_profiles) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.resultsExperimentAbundance_widget.sizePolicy().hasHeightForWidth()) + self.resultsExperimentAbundance_widget.setSizePolicy(sizePolicy) + self.resultsExperimentAbundance_widget.setMinimumSize(QtCore.QSize(700, 500)) + self.resultsExperimentAbundance_widget.setObjectName(_fromUtf8("resultsExperimentAbundance_widget")) + self.gridLayout_abundance_profiles.addWidget(self.resultsExperimentAbundance_widget, 1, 0, 1, 1) + self.tabWidget_3.addTab(self.tab_abundance_profiles, _fromUtf8("")) self.gridLayout_40.addWidget(self.tabWidget_3, 3, 1, 1, 1) self.scrollArea.setWidget(self.scrollAreaWidgetContents) self.gridLayout_2.addWidget(self.scrollArea, 0, 0, 1, 1) @@ -3327,6 +3362,16 @@ def retranslateUi(self, MainWindow): self.tabWidget_3.indexOf(self.tab_peak_details), _translate("MainWindow", "Peak details", None), ) + self.label_abundance_visualization.setText(_translate("MainWindow", "Visualization", None)) + self.comboBox_abundancePlotType.setItemText(0, _translate("MainWindow", "Boxplot", None)) + self.comboBox_abundancePlotType.setItemText(1, _translate("MainWindow", "Line plot", None)) + self.label_abundance_scale.setText(_translate("MainWindow", "Scale", None)) + self.comboBox_abundanceScale.setItemText(0, _translate("MainWindow", "Linear", None)) + self.comboBox_abundanceScale.setItemText(1, _translate("MainWindow", "Logarithmic", None)) + self.tabWidget_3.setTabText( + self.tabWidget_3.indexOf(self.tab_abundance_profiles), + _translate("MainWindow", "Abundance profiles", None), + ) self.tabWidget.setTabText( self.tabWidget.indexOf(self.bracketedResultsTab), _translate("MainWindow", "Experiment results", None), From ffe00cfc052d2d7c6ea3a2668b7aa2386346fd4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:07:15 +0000 Subject: [PATCH 09/27] Polish abundance profile plot axis label handling Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/0c40c85f-6bb6-4c4c-a7fd-34df71bbc343 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index d0c9cad..b327c85 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -2273,7 +2273,7 @@ def updateExperimentAbundancePlot(self, plotItems): sample_name = re.sub(r"\.(mzxml|mzml)$", "", sample_name, flags=re.IGNORECASE) sample_entries.append((group_name, sample_name)) - sample_entries = sorted(set(sample_entries), key=lambda x: (str(x[0]).lower(), str(x[1]).lower())) + sample_entries = sorted(set(sample_entries), key=lambda x: (x[0].lower(), x[1].lower())) abundance_suffix = "_Abundance_N" if not any(f"{entry[1]}{abundance_suffix}" in table_df.columns for entry in sample_entries): abundance_suffix = "_Area_N" @@ -2298,7 +2298,7 @@ def updateExperimentAbundancePlot(self, plotItems): row = rows_by_num.get(feature_id) if row is None: continue - for group_name in sorted(set([entry[0] for entry in sample_entries]), key=lambda x: str(x).lower()): + for group_name in sorted(set([entry[0] for entry in sample_entries]), key=lambda x: x.lower()): vals = [] for grp_name, sample_name in sample_entries: if grp_name != group_name: @@ -2326,7 +2326,7 @@ def updateExperimentAbundancePlot(self, plotItems): for patch, c in zip(bp["boxes"], box_colors): patch.set_facecolor(c) patch.set_alpha(0.35) - ax.tick_params(axis="x", rotation=45) + ax.set_xticklabels(box_labels, rotation=45, ha="right") else: ax.text(0.5, 0.5, "No abundance values available", transform=ax.transAxes, ha="center", va="center") else: From 670e6171eba6e6589944d000c3726abeb3e9a54e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:21:54 +0000 Subject: [PATCH 10/27] Refine abundance plotting and zero-aware abundance grouping Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/a0d0f9b7-14bc-4f1d-9746-2488bdac0967 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- ...eriment-results-abundance-profiles-tab.png | Bin 16827 -> 19357 bytes src/MExtract.py | 122 ++++++++----- src/mePyGuis/guis/mainwindow.ui | 26 +++ src/mePyGuis/mainWindow.py | 13 ++ src/metaboliteGrouping.py | 168 ++++++++++++------ tests/test_metabolite_grouping.py | 15 ++ 6 files changed, 240 insertions(+), 104 deletions(-) diff --git a/docs/screenshots/experiment-results-abundance-profiles-tab.png b/docs/screenshots/experiment-results-abundance-profiles-tab.png index 9ab7b2daa672b3c706b00e2395253cf026287b56..c39cd3436009883b697253b15d81d41ec2767985 100644 GIT binary patch literal 19357 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 0: + for key in list(values.keys()): + values[key] = values[key] / ref ax = self.ui.resultsExperimentAbundance_plot.axes ax.set_title("Abundance profiles of selected features") - ax.set_xlabel("Sample") ax.set_ylabel(f"Abundance ({'log' if log_scale else 'linear'} scale)") + if scaling_mode != "None": + ax.set_ylabel(f"Relative abundance ({'log' if log_scale else 'linear'} scale)") if "Boxplot" in plot_type: + feature_ids = sorted(feature_sample_values.keys()) box_data = [] - box_labels = [] + positions = [] box_colors = [] - for feature_id in sorted(set(selected_ids)): - row = rows_by_num.get(feature_id) - if row is None: - continue - for group_name in sorted(set([entry[0] for entry in sample_entries]), key=lambda x: x.lower()): - vals = [] - for grp_name, sample_name in sample_entries: - if grp_name != group_name: - continue - col = sample_name + abundance_suffix - if col not in table_df.columns: - continue - val = row.get(col) - if val in [None, ""]: - continue - try: - val = float(val) - except Exception: - continue - if log_scale and val <= 0: - continue - vals.append(val) - if vals: - box_data.append(vals) - box_labels.append(f"{feature_id}\n{group_name}") - box_colors.append(color_map.get(group_name, "gray")) + legend_handles = [] + if len(feature_ids) > 0 and len(group_names) > 0: + box_width = 0.8 / max(1, len(feature_ids)) + for feature_index, feature_id in enumerate(feature_ids): + color = f"C{feature_index % 10}" + legend_handles.append(patches.Patch(facecolor=color, alpha=0.35, label=f"Feature {feature_id}")) + offset = (feature_index - (len(feature_ids) - 1) / 2.0) * box_width + for group_index, group_name in enumerate(group_names): + vals = [] + for sample_name in samples_by_group.get(group_name, []): + key = (group_name, sample_name) + if key not in feature_sample_values[feature_id]: + continue + value = feature_sample_values[feature_id][key] + if log_scale and value <= 0: + continue + vals.append(value) + if vals: + box_data.append(vals) + positions.append(group_index + offset) + box_colors.append(color) if box_data: - bp = ax.boxplot(box_data, patch_artist=True, labels=box_labels) + bp = ax.boxplot(box_data, positions=positions, widths=0.8 / max(1, len(feature_sample_values)), patch_artist=True) for patch, c in zip(bp["boxes"], box_colors): patch.set_facecolor(c) patch.set_alpha(0.35) - ax.set_xticklabels(box_labels, rotation=45, ha="right") + ax.set_xticks(list(range(len(group_names)))) + ax.set_xticklabels(group_names, rotation=25, ha="right") + ax.set_xlabel("Experimental group") + if legend_handles: + ax.legend(handles=legend_handles, loc="best") else: ax.text(0.5, 0.5, "No abundance values available", transform=ax.transAxes, ha="center", va="center") else: @@ -2339,22 +2375,15 @@ def updateExperimentAbundancePlot(self, plotItems): group_changes.append(i - 0.5) last_group = grp - for feature_id in sorted(set(selected_ids)): - row = rows_by_num.get(feature_id) - if row is None: - continue + ax.set_xlabel("Sample") + for feature_id in sorted(feature_sample_values.keys()): y_vals = [] for grp, sample in sample_entries: - col = sample + abundance_suffix - val = row.get(col) if col in table_df.columns else None - if val in [None, ""]: - y_vals.append(float("nan")) - continue - try: - val = float(val) - except Exception: + key = (grp, sample) + if key not in feature_sample_values[feature_id]: y_vals.append(float("nan")) continue + val = feature_sample_values[feature_id][key] if log_scale and val <= 0: y_vals.append(float("nan")) else: @@ -11163,6 +11192,7 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal self.ui.showLegend_experiment.stateChanged.connect(self._refreshExperimentEICs) self.ui.comboBox_abundancePlotType.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) self.ui.comboBox_abundanceScale.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) + self.ui.comboBox_abundanceScalingMode.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) self.ui.eicSmoothingWindow.currentIndexChanged.connect(self.smoothingWindowChanged) self.smoothingWindowChanged() diff --git a/src/mePyGuis/guis/mainwindow.ui b/src/mePyGuis/guis/mainwindow.ui index 83cc531..5c130f0 100644 --- a/src/mePyGuis/guis/mainwindow.ui +++ b/src/mePyGuis/guis/mainwindow.ui @@ -5824,6 +5824,32 @@ font: 7pt; + + + + Normalization + + + + + + + + None + + + + + Scale to max sample + + + + + Scale to max experimental group + + + + diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 3615b8a..c3fa038 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -2662,6 +2662,15 @@ def setupUi(self, MainWindow): self.comboBox_abundanceScale.addItem(_fromUtf8("")) self.comboBox_abundanceScale.addItem(_fromUtf8("")) self.horizontalLayout_abundance_controls.addWidget(self.comboBox_abundanceScale) + self.label_abundance_scaling = QtWidgets.QLabel(self.tab_abundance_profiles) + self.label_abundance_scaling.setObjectName(_fromUtf8("label_abundance_scaling")) + self.horizontalLayout_abundance_controls.addWidget(self.label_abundance_scaling) + self.comboBox_abundanceScalingMode = QtWidgets.QComboBox(self.tab_abundance_profiles) + self.comboBox_abundanceScalingMode.setObjectName(_fromUtf8("comboBox_abundanceScalingMode")) + self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) + self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) + self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) + self.horizontalLayout_abundance_controls.addWidget(self.comboBox_abundanceScalingMode) spacerItem74 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_abundance_controls.addItem(spacerItem74) self.gridLayout_abundance_profiles.addLayout(self.horizontalLayout_abundance_controls, 0, 0, 1, 1) @@ -3368,6 +3377,10 @@ def retranslateUi(self, MainWindow): self.label_abundance_scale.setText(_translate("MainWindow", "Scale", None)) self.comboBox_abundanceScale.setItemText(0, _translate("MainWindow", "Linear", None)) self.comboBox_abundanceScale.setItemText(1, _translate("MainWindow", "Logarithmic", None)) + self.label_abundance_scaling.setText(_translate("MainWindow", "Normalization", None)) + self.comboBox_abundanceScalingMode.setItemText(0, _translate("MainWindow", "None", None)) + self.comboBox_abundanceScalingMode.setItemText(1, _translate("MainWindow", "Scale to max sample", None)) + self.comboBox_abundanceScalingMode.setItemText(2, _translate("MainWindow", "Scale to max experimental group", None)) self.tabWidget_3.setTabText( self.tabWidget_3.indexOf(self.tab_abundance_profiles), _translate("MainWindow", "Abundance profiles", None), diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 8a716cf..1dc49fe 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -1,47 +1,7 @@ import numpy as np -def split_group_with_hca(group_ids, similarities, min_peak_correlation, min_connection_rate): - from . import HCA_general - - if len(group_ids) <= 2: - return [group_ids] - - data = [] - for feature_a in group_ids: - row = [] - for feature_b in group_ids: - if feature_a in similarities and feature_b in similarities[feature_a]: - row.append(similarities[feature_a][feature_b]) - elif feature_a == feature_b: - row.append(1.0) - else: - row.append(0.0) - data.append(row) - - hca = HCA_general.HCA_generic() - tree = hca.generateTree(objs=data, ids=group_ids) - - def check_sub_cluster(tree, hca, corr_threshold, cut_off_min_ratio): - if isinstance(tree, HCA_general.HCALeaf): - return False - elif isinstance(tree, HCA_general.HCAComposite): - corrs = hca.getLinkFor(tree) - inds = hca.getIndsFor(tree) - if len(inds) == 0: - return False - inds_set = set(inds) - - above_threshold = sum(corr > corr_threshold for i, corr in enumerate(corrs) if i in inds_set) - return not (above_threshold * 1.0 / len(inds)) >= cut_off_min_ratio - else: - raise RuntimeError(f"Unexpected tree node type: {type(tree).__name__}. Expected HCALeaf or HCAComposite") - - sub_clusters = hca.splitTreeWithCallback(tree, lambda cluster, hca: check_sub_cluster(cluster, hca, min_peak_correlation, min_connection_rate), recursive=True) - return [[leaf.getID() for leaf in sub_cluster.getLeaves()] for sub_cluster in sub_clusters] - - -def _normalize_relative_abundance_profile(values): +def _coerce_abundance_profile(values): profile = [] for value in values: try: @@ -51,25 +11,113 @@ def _normalize_relative_abundance_profile(values): except (TypeError, ValueError): numeric = 0.0 profile.append(max(0.0, numeric)) - - total = sum(profile) - if total > 0: - profile = [value / total for value in profile] return profile -def _profile_correlation(profile_a, profile_b): +def _pearson(values_a, values_b): + if len(values_a) != len(values_b) or len(values_a) < 2: + return None + if np.std(values_a) == 0 or np.std(values_b) == 0: + return None + corr = np.corrcoef(values_a, values_b)[0, 1] + if np.isnan(corr): + return None + return float(corr) + + +def _presence_aware_profile_similarity(profile_a, profile_b, both_missing_weight=0.25): if profile_a is None or profile_b is None: return 0.0 if len(profile_a) < 2 or len(profile_b) < 2 or len(profile_a) != len(profile_b): return 0.0 - if np.std(profile_a) == 0 or np.std(profile_b) == 0: + + quantified_a = [] + quantified_b = [] + both_missing_count = 0 + + for value_a, value_b in zip(profile_a, profile_b): + has_a = value_a > 0.0 + has_b = value_b > 0.0 + if has_a and has_b: + quantified_a.append(value_a) + quantified_b.append(value_b) + elif (not has_a) and (not has_b): + both_missing_count += 1 + # samples where only one feature has abundance are omitted completely + + quantified_count = len(quantified_a) + if quantified_count == 0 and both_missing_count == 0: return 0.0 - corr = np.corrcoef(profile_a, profile_b)[0, 1] - if np.isnan(corr): + quantified_corr = _pearson(quantified_a, quantified_b) + if quantified_corr is None: + quantified_corr = 1.0 if quantified_count == 1 else 0.0 + + weighted_sum = quantified_count * quantified_corr + both_missing_weight * both_missing_count + total_weight = quantified_count + both_missing_weight * both_missing_count + if total_weight <= 0: return 0.0 - return float(corr) + + return float(weighted_sum / total_weight) + + +def _connected_components(group_ids, adjacency): + remaining = set(group_ids) + components = [] + while remaining: + root = remaining.pop() + stack = [root] + component = {root} + while stack: + node = stack.pop() + for neighbor in adjacency.get(node, set()): + if neighbor in remaining: + remaining.remove(neighbor) + component.add(neighbor) + stack.append(neighbor) + components.append(component) + return components + + +def _split_component_by_connection_rate(component_ids, adjacency, min_connection_rate): + if len(component_ids) <= 2: + return [sorted(component_ids)] + + component_set = set(component_ids) + changed = True + while changed and len(component_set) > 2: + changed = False + min_required_connections = max(0.0, min_connection_rate) * (len(component_set) - 1) + to_remove = set() + for node in component_set: + degree = sum(1 for neighbor in adjacency.get(node, set()) if neighbor in component_set) + if degree < min_required_connections: + to_remove.add(node) + if to_remove: + component_set -= to_remove + changed = True + + if not component_set: + return [[feature_id] for feature_id in sorted(component_ids)] + + result = [] + kept_components = _connected_components(component_set, adjacency) + for kept_component in kept_components: + if kept_component == set(component_ids): + result.append(sorted(kept_component)) + else: + result.extend(_split_component_by_connection_rate(sorted(kept_component), adjacency, min_connection_rate)) + + removed = set(component_ids) - component_set + if removed: + removed_components = _connected_components(removed, adjacency) + for removed_component in removed_components: + if len(removed_component) <= 1: + result.append(sorted(removed_component)) + else: + result.extend(_split_component_by_connection_rate(sorted(removed_component), adjacency, min_connection_rate)) + + return result def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_correlation, min_connection_rate): @@ -79,7 +127,7 @@ def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_cor profiles = {} for feature_id in group_ids: if feature_id in abundance_vectors: - profiles[feature_id] = _normalize_relative_abundance_profile(abundance_vectors[feature_id]) + profiles[feature_id] = _coerce_abundance_profile(abundance_vectors[feature_id]) if len(profiles) < len(group_ids): return [group_ids] @@ -88,16 +136,20 @@ def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_cor return [group_ids] similarities = {feature_id: {} for feature_id in group_ids} + adjacency = {feature_id: set() for feature_id in group_ids} for i in range(len(group_ids)): for j in range(i + 1, len(group_ids)): feature_a = group_ids[i] feature_b = group_ids[j] - similarities[feature_a][feature_b] = _profile_correlation(profiles[feature_a], profiles[feature_b]) + similarities[feature_a][feature_b] = _presence_aware_profile_similarity(profiles[feature_a], profiles[feature_b]) similarities[feature_b][feature_a] = similarities[feature_a][feature_b] + if similarities[feature_a][feature_b] >= min_peak_correlation: + adjacency[feature_a].add(feature_b) + adjacency[feature_b].add(feature_a) + + components = _connected_components(group_ids, adjacency) + refined_groups = [] + for component in components: + refined_groups.extend(_split_component_by_connection_rate(sorted(component), adjacency, min_connection_rate)) - return split_group_with_hca( - group_ids, - similarities, - min_peak_correlation=min_peak_correlation, - min_connection_rate=min_connection_rate, - ) + return sorted([sorted(group) for group in refined_groups], key=lambda group: (len(group), group), reverse=True) diff --git a/tests/test_metabolite_grouping.py b/tests/test_metabolite_grouping.py index 8f160ec..8a53fb2 100644 --- a/tests/test_metabolite_grouping.py +++ b/tests/test_metabolite_grouping.py @@ -57,3 +57,18 @@ def test_split_group_by_relative_abundance_respects_similarity_threshold(): assert _normalize_groups(relaxed_groups) == [[1, 2, 3]] assert _normalize_groups(strict_groups) == [[1, 2], [3]] + + +def test_split_group_by_relative_abundance_ignores_mismatched_zero_presence(): + groups = split_group_by_relative_abundance( + [1, 2, 3], + { + 1: [10, 0, 11, 0], + 2: [9, 0, 12, 0], + 3: [0, 20, 0, 18], + }, + min_peak_correlation=0.7, + min_connection_rate=0.6, + ) + + assert _normalize_groups(groups) == [[1, 2], [3]] From 6a8aba39522d4054e9df416ddd627cb2c5386dc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:42:24 +0000 Subject: [PATCH 11/27] Refine post-reintegration grouping and boxplot grouping layout Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/ab6fb312-3468-4e96-b42f-736da2228c5d Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- ...eriment-results-abundance-profiles-tab.png | Bin 19357 -> 19102 bytes src/MExtract.py | 169 +++++++++--------- src/metaboliteGrouping.py | 91 ++++++---- tests/test_metabolite_grouping.py | 15 ++ 4 files changed, 160 insertions(+), 115 deletions(-) diff --git a/docs/screenshots/experiment-results-abundance-profiles-tab.png b/docs/screenshots/experiment-results-abundance-profiles-tab.png index c39cd3436009883b697253b15d81d41ec2767985..5932ddef22783de5f0c00a832913e675a1f6c5e1 100644 GIT binary patch literal 19102 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

s?JFe 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 Bh^_zt literal 19357 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 0 and len(group_names) > 0: - box_width = 0.8 / max(1, len(feature_ids)) + cluster_width = 0.75 + slot_width = cluster_width / max(1, len(feature_ids)) + box_width = slot_width * 0.8 for feature_index, feature_id in enumerate(feature_ids): color = f"C{feature_index % 10}" legend_handles.append(patches.Patch(facecolor=color, alpha=0.35, label=f"Feature {feature_id}")) - offset = (feature_index - (len(feature_ids) - 1) / 2.0) * box_width for group_index, group_name in enumerate(group_names): + offset = (-cluster_width / 2.0) + (feature_index + 0.5) * slot_width vals = [] for sample_name in samples_by_group.get(group_name, []): key = (group_name, sample_name) @@ -2354,7 +2356,7 @@ def updateExperimentAbundancePlot(self, plotItems): box_colors.append(color) if box_data: - bp = ax.boxplot(box_data, positions=positions, widths=0.8 / max(1, len(feature_sample_values)), patch_artist=True) + bp = ax.boxplot(box_data, positions=positions, widths=box_width, patch_artist=True) for patch, c in zip(bp["boxes"], box_colors): patch.set_facecolor(c) patch.set_alpha(0.35) @@ -3767,6 +3769,87 @@ def runProcess(self, dontSave=False, askStarting=True): if self.terminateJobs: return + convolution_input_sheet = "2_StatColumns" + annotation_input_sheet = "2_StatColumns" + + # re-integrate missed peaks (run before grouping, if enabled) + if self.ui.integratedMissedPeaks.isChecked(): + logging.info("\n\n##############################################################") + logging.info("Re-integrating of individual LC-HRMS results..") + + pw = ProgressWrapper(min(len(files), cpus) + 1, showLog=False, parent=self) + pw.show() + pw.getCallingFunction()("text")("Integrating..") + pw.getCallingFunction()("header")("Integrating..") + + try: + # Reintegrate missed peaks in files + fDict = {} + for group in definedGroups: + for grp in natSort(group.files): + f = grp + f = f.replace("\\", "/") + fDict[f] = f[(f.rfind("/") + 1) : max(f.lower().rfind(".mzxml"), f.lower().rfind(".mzml"))] + + reIntegrateResultsFile( + excel_file, + "2_StatColumns", + "4_Reintegrated", + fDict, + addPeakArea=bool(self.ui.checkBox_expPeakArea.checkState() == QtCore.Qt.Checked), + addPeakAbundance=bool(self.ui.checkBox_expPeakApexIntensity.checkState() == QtCore.Qt.Checked), + addPeakSNR=bool(self.ui.checkBox_expPeakSNR.checkState() == QtCore.Qt.Checked), + ppm=self.ui.groupPpm.value(), + maxRTShift=self.ui.integrationMaxTimeDifference.value(), + scales=[ + self.ui.wavelet_minScale.value(), + self.ui.wavelet_maxScale.value(), + ], + reintegrateIntensityCutoff=self.ui.reintegrateIntensityCutoff.value(), + snrTH=self.ui.wavelet_SNRThreshold.value(), + smoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), + smoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), + smoothingWindowPolynom=self.ui.smoothingPolynom_spinner.value(), + positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), + negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), + pw=pw, + selfObj=self, + cpus=min(len(files), cpus), + start=start, + peak_filter_config=filter_config, + peak_picker=picker, + ) + convolution_input_sheet = "4_Reintegrated" + annotation_input_sheet = "4_Reintegrated" + + # Log time used for bracketing + elapsed = (time.time() - start) / 60.0 + hours = "" + if elapsed >= 60.0: + hours = "%d hours " % (elapsed // 60) + mins = "%.2f mins" % (elapsed % 60.0) + logging.info("Re-integrating finished (%s%s).." % (hours, mins)) + + except Exception as e: + traceback.print_exc() + logging.error(str(traceback)) + + QtWidgets.QMessageBox.warning( + self, + "MetExtract", + "Error during reintegrating files: '%s'" % str(e), + QtWidgets.QMessageBox.Ok, + ) + errorCount += 1 + finally: + pw.setSkipCallBack(True) + pw.hide() + logging.info("##############################################################") + + if self.terminateJobs: + pw.hide() + return + # Calculate metabolite groups if self.ui.convoluteResults.isChecked(): logging.info("\n\n##############################################################") @@ -3838,7 +3921,7 @@ def runProcess(self, dontSave=False, askStarting=True): _target=calculateMetaboliteGroups, file=excel_file, toFile=excel_file, - sheet_name="2_StatColumns", + sheet_name=convolution_input_sheet, new_sheet_name="3_Convoluted", groups=definedGroups, eicPPM=self.ui.wavelet_EICppm.value(), @@ -3900,6 +3983,7 @@ def runProcess(self, dontSave=False, askStarting=True): return else: logging.info("Convoluting feature pairs finished (%s%s).." % (hours, mins)) + annotation_input_sheet = "3_Convoluted" except Exception as ex: traceback.print_exc() @@ -3922,81 +4006,6 @@ def runProcess(self, dontSave=False, askStarting=True): if self.terminateJobs: return - # re-integrate missed peaks - if self.ui.integratedMissedPeaks.isChecked(): - logging.info("\n\n##############################################################") - logging.info("Re-integrating of individual LC-HRMS results..") - - pw = ProgressWrapper(min(len(files), cpus) + 1, showLog=False, parent=self) - pw.show() - pw.getCallingFunction()("text")("Integrating..") - pw.getCallingFunction()("header")("Integrating..") - - try: - # Reintegrate missed peaks in files - fDict = {} - for group in definedGroups: - for grp in natSort(group.files): - f = grp - f = f.replace("\\", "/") - fDict[f] = f[(f.rfind("/") + 1) : max(f.lower().rfind(".mzxml"), f.lower().rfind(".mzml"))] - - reIntegrateResultsFile( - excel_file, - "3_Convoluted", - "4_Reintegrated", - fDict, - addPeakArea=bool(self.ui.checkBox_expPeakArea.checkState() == QtCore.Qt.Checked), - addPeakAbundance=bool(self.ui.checkBox_expPeakApexIntensity.checkState() == QtCore.Qt.Checked), - addPeakSNR=bool(self.ui.checkBox_expPeakSNR.checkState() == QtCore.Qt.Checked), - ppm=self.ui.groupPpm.value(), - maxRTShift=self.ui.integrationMaxTimeDifference.value(), - scales=[ - self.ui.wavelet_minScale.value(), - self.ui.wavelet_maxScale.value(), - ], - reintegrateIntensityCutoff=self.ui.reintegrateIntensityCutoff.value(), - snrTH=self.ui.wavelet_SNRThreshold.value(), - smoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), - smoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), - smoothingWindowPolynom=self.ui.smoothingPolynom_spinner.value(), - positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), - negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), - pw=pw, - selfObj=self, - cpus=min(len(files), cpus), - start=start, - peak_filter_config=filter_config, - peak_picker=picker, - ) - # Log time used for bracketing - elapsed = (time.time() - start) / 60.0 - hours = "" - if elapsed >= 60.0: - hours = "%d hours " % (elapsed // 60) - mins = "%.2f mins" % (elapsed % 60.0) - logging.info("Re-integrating finished (%s%s).." % (hours, mins)) - - except Exception as e: - traceback.print_exc() - logging.error(str(traceback)) - - QtWidgets.QMessageBox.warning( - self, - "MetExtract", - "Error during reintegrating files: '%s'" % str(e), - QtWidgets.QMessageBox.Ok, - ) - errorCount += 1 - finally: - pw.setSkipCallBack(True) - pw.hide() - logging.info("##############################################################") - - if self.terminateJobs: - pw.hide() - return - annotationColumns = [] ## annotate results if self.ui.annotateMetabolites_CheckBox.isChecked(): @@ -4037,7 +4046,7 @@ def runProcess(self, dontSave=False, askStarting=True): try: addedColumns = annotateResultMatrix.annotateWithDatabases( file=excel_file, - sheet_name="4_Reintegrated", + sheet_name=annotation_input_sheet, new_sheet_name="5_Annotated", dbFiles=dbFiles, useAdducts=useAdducts, diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 1dc49fe..94972b5 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -18,7 +18,14 @@ def _pearson(values_a, values_b): if len(values_a) != len(values_b) or len(values_a) < 2: return None if np.std(values_a) == 0 or np.std(values_b) == 0: - return None + max_a = max(values_a) if len(values_a) > 0 else 0.0 + max_b = max(values_b) if len(values_b) > 0 else 0.0 + if max_a > 0 and max_b > 0: + norm_a = [a / max_a for a in values_a] + norm_b = [b / max_b for b in values_b] + if np.allclose(norm_a, norm_b, rtol=1e-6, atol=1e-9): + return 1.0 + return 0.0 corr = np.corrcoef(values_a, values_b)[0, 1] if np.isnan(corr): return None @@ -79,45 +86,59 @@ def _connected_components(group_ids, adjacency): return components -def _split_component_by_connection_rate(component_ids, adjacency, min_connection_rate): +def _avg_similarity_to_group(node, group, similarities): + vals = [] + for other in group: + if node == other: + continue + vals.append(similarities.get(node, {}).get(other, 0.0)) + if len(vals) == 0: + return 0.0 + return float(sum(vals) / len(vals)) + + +def _connection_rate_to_group(node, group, adjacency): + if len(group) == 0: + return 0.0 + degree = sum(1 for neighbor in adjacency.get(node, set()) if neighbor in group) + return float(degree) / float(len(group)) + + +def _split_component_by_connection_rate(component_ids, adjacency, similarities, min_connection_rate): if len(component_ids) <= 2: return [sorted(component_ids)] component_set = set(component_ids) - changed = True - while changed and len(component_set) > 2: - changed = False - min_required_connections = max(0.0, min_connection_rate) * (len(component_set) - 1) - to_remove = set() - for node in component_set: - degree = sum(1 for neighbor in adjacency.get(node, set()) if neighbor in component_set) - if degree < min_required_connections: - to_remove.add(node) - if to_remove: - component_set -= to_remove - changed = True - - if not component_set: - return [[feature_id] for feature_id in sorted(component_ids)] - - result = [] - kept_components = _connected_components(component_set, adjacency) - for kept_component in kept_components: - if kept_component == set(component_ids): - result.append(sorted(kept_component)) - else: - result.extend(_split_component_by_connection_rate(sorted(kept_component), adjacency, min_connection_rate)) + min_required_connections = max(0.0, min_connection_rate) * (len(component_set) - 1) + dense_nodes = set() + for node in component_set: + degree = sum(1 for neighbor in adjacency.get(node, set()) if neighbor in component_set) + if degree >= min_required_connections: + dense_nodes.add(node) + + # avoid over-splitting if no dense core can be formed + if len(dense_nodes) == 0: + return [sorted(component_ids)] - removed = set(component_ids) - component_set - if removed: - removed_components = _connected_components(removed, adjacency) - for removed_component in removed_components: - if len(removed_component) <= 1: - result.append(sorted(removed_component)) - else: - result.extend(_split_component_by_connection_rate(sorted(removed_component), adjacency, min_connection_rate)) + dense_groups = _connected_components(dense_nodes, adjacency) + groups = [set(group) for group in dense_groups] + + remaining = sorted(component_set - dense_nodes) + for node in remaining: + best_group = None + best_score = None + for group in groups: + if _connection_rate_to_group(node, group, adjacency) >= min_connection_rate: + score = _avg_similarity_to_group(node, group, similarities) + if best_score is None or score > best_score: + best_group = group + best_score = score + if best_group is not None: + best_group.add(node) + else: + groups.append({node}) - return result + return [sorted(group) for group in groups] def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_correlation, min_connection_rate): @@ -150,6 +171,6 @@ def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_cor components = _connected_components(group_ids, adjacency) refined_groups = [] for component in components: - refined_groups.extend(_split_component_by_connection_rate(sorted(component), adjacency, min_connection_rate)) + refined_groups.extend(_split_component_by_connection_rate(sorted(component), adjacency, similarities, min_connection_rate)) return sorted([sorted(group) for group in refined_groups], key=lambda group: (len(group), group), reverse=True) diff --git a/tests/test_metabolite_grouping.py b/tests/test_metabolite_grouping.py index 8a53fb2..33e9359 100644 --- a/tests/test_metabolite_grouping.py +++ b/tests/test_metabolite_grouping.py @@ -72,3 +72,18 @@ def test_split_group_by_relative_abundance_ignores_mismatched_zero_presence(): ) assert _normalize_groups(groups) == [[1, 2], [3]] + + +def test_split_group_by_relative_abundance_keeps_constant_proportional_profiles_together(): + groups = split_group_by_relative_abundance( + [1, 2, 3], + { + 1: [10, 10, 0, 0], + 2: [20, 20, 0, 0], + 3: [0, 0, 30, 30], + }, + min_peak_correlation=0.8, + min_connection_rate=0.6, + ) + + assert _normalize_groups(groups) == [[1, 2], [3]] From ec988114abde5973f08aa3fa66ac1bda8e3c4e7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:44:13 +0000 Subject: [PATCH 12/27] Address validation feedback on grouping and plot code clarity Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/ab6fb312-3468-4e96-b42f-736da2228c5d Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 2 ++ src/metaboliteGrouping.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 493122c..b40dc7c 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -2333,6 +2333,7 @@ def updateExperimentAbundancePlot(self, plotItems): box_colors = [] legend_handles = [] if len(feature_ids) > 0 and len(group_names) > 0: + # keep each group's feature boxes tightly clustered while still visibly separated cluster_width = 0.75 slot_width = cluster_width / max(1, len(feature_ids)) box_width = slot_width * 0.8 @@ -3770,6 +3771,7 @@ def runProcess(self, dontSave=False, askStarting=True): return convolution_input_sheet = "2_StatColumns" + # tracked separately because grouping may run after re-integration and update only annotation input afterwards annotation_input_sheet = "2_StatColumns" # re-integrate missed peaks (run before grouping, if enabled) diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 94972b5..6ce525f 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -18,8 +18,8 @@ def _pearson(values_a, values_b): if len(values_a) != len(values_b) or len(values_a) < 2: return None if np.std(values_a) == 0 or np.std(values_b) == 0: - max_a = max(values_a) if len(values_a) > 0 else 0.0 - max_b = max(values_b) if len(values_b) > 0 else 0.0 + max_a = max(values_a) + max_b = max(values_b) if max_a > 0 and max_b > 0: norm_a = [a / max_a for a in values_a] norm_b = [b / max_b for b in values_b] @@ -104,7 +104,7 @@ def _connection_rate_to_group(node, group, adjacency): return float(degree) / float(len(group)) -def _split_component_by_connection_rate(component_ids, adjacency, similarities, min_connection_rate): +def _split_component_by_dense_subclusters(component_ids, adjacency, similarities, min_connection_rate): if len(component_ids) <= 2: return [sorted(component_ids)] @@ -171,6 +171,6 @@ def split_group_by_relative_abundance(group_ids, abundance_vectors, min_peak_cor components = _connected_components(group_ids, adjacency) refined_groups = [] for component in components: - refined_groups.extend(_split_component_by_connection_rate(sorted(component), adjacency, similarities, min_connection_rate)) + refined_groups.extend(_split_component_by_dense_subclusters(sorted(component), adjacency, similarities, min_connection_rate)) return sorted([sorted(group) for group in refined_groups], key=lambda group: (len(group), group), reverse=True) From 1a3446b4798529a8862030c333fa459cef0f0875 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:45:44 +0000 Subject: [PATCH 13/27] Polish constant naming and grouping comments Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/ab6fb312-3468-4e96-b42f-736da2228c5d Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 9 +++++---- src/metaboliteGrouping.py | 8 ++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index b40dc7c..e7a1852 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -2334,14 +2334,15 @@ def updateExperimentAbundancePlot(self, plotItems): legend_handles = [] if len(feature_ids) > 0 and len(group_names) > 0: # keep each group's feature boxes tightly clustered while still visibly separated - cluster_width = 0.75 - slot_width = cluster_width / max(1, len(feature_ids)) - box_width = slot_width * 0.8 + boxplot_cluster_width = 0.75 + boxplot_slot_fill_ratio = 0.8 + slot_width = boxplot_cluster_width / max(1, len(feature_ids)) + box_width = slot_width * boxplot_slot_fill_ratio for feature_index, feature_id in enumerate(feature_ids): color = f"C{feature_index % 10}" legend_handles.append(patches.Patch(facecolor=color, alpha=0.35, label=f"Feature {feature_id}")) for group_index, group_name in enumerate(group_names): - offset = (-cluster_width / 2.0) + (feature_index + 0.5) * slot_width + offset = (-boxplot_cluster_width / 2.0) + (feature_index + 0.5) * slot_width vals = [] for sample_name in samples_by_group.get(group_name, []): key = (group_name, sample_name) diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index 6ce525f..c28654f 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -1,5 +1,8 @@ import numpy as np +NORMALIZED_PROFILE_RTOL = 1e-6 +NORMALIZED_PROFILE_ATOL = 1e-9 + def _coerce_abundance_profile(values): profile = [] @@ -23,7 +26,7 @@ def _pearson(values_a, values_b): if max_a > 0 and max_b > 0: norm_a = [a / max_a for a in values_a] norm_b = [b / max_b for b in values_b] - if np.allclose(norm_a, norm_b, rtol=1e-6, atol=1e-9): + if np.allclose(norm_a, norm_b, rtol=NORMALIZED_PROFILE_RTOL, atol=NORMALIZED_PROFILE_ATOL): return 1.0 return 0.0 corr = np.corrcoef(values_a, values_b)[0, 1] @@ -116,7 +119,8 @@ def _split_component_by_dense_subclusters(component_ids, adjacency, similarities if degree >= min_required_connections: dense_nodes.add(node) - # avoid over-splitting if no dense core can be formed + # avoid over-splitting: if no node reaches the connection-rate threshold, keep this + # component intact rather than fragmenting nearly all nodes into singleton groups if len(dense_nodes) == 0: return [sorted(component_ids)] From 3454eb5d9ce0eecb06a4f81d2e50d2c6954d96c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 10:47:19 +0000 Subject: [PATCH 14/27] Document grouping helpers and centralize boxplot constants Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/ab6fb312-3468-4e96-b42f-736da2228c5d Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 12 +++++++----- src/metaboliteGrouping.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index e7a1852..3fd155d 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -105,6 +105,10 @@ TRACER = object() METABOLOME = object() +# Boxplot layout constants for abundance-profile group comparison plots +ABUNDANCE_BOXPLOT_CLUSTER_WIDTH = 0.75 +ABUNDANCE_BOXPLOT_SLOT_FILL_RATIO = 0.8 + # Helper function to safely load pickled data with error handling for old cached data def safe_pickle_loads(data, default_value=None, operation_name="loading data"): @@ -2334,15 +2338,13 @@ def updateExperimentAbundancePlot(self, plotItems): legend_handles = [] if len(feature_ids) > 0 and len(group_names) > 0: # keep each group's feature boxes tightly clustered while still visibly separated - boxplot_cluster_width = 0.75 - boxplot_slot_fill_ratio = 0.8 - slot_width = boxplot_cluster_width / max(1, len(feature_ids)) - box_width = slot_width * boxplot_slot_fill_ratio + slot_width = ABUNDANCE_BOXPLOT_CLUSTER_WIDTH / max(1, len(feature_ids)) + box_width = slot_width * ABUNDANCE_BOXPLOT_SLOT_FILL_RATIO for feature_index, feature_id in enumerate(feature_ids): color = f"C{feature_index % 10}" legend_handles.append(patches.Patch(facecolor=color, alpha=0.35, label=f"Feature {feature_id}")) for group_index, group_name in enumerate(group_names): - offset = (-boxplot_cluster_width / 2.0) + (feature_index + 0.5) * slot_width + offset = (-ABUNDANCE_BOXPLOT_CLUSTER_WIDTH / 2.0) + (feature_index + 0.5) * slot_width vals = [] for sample_name in samples_by_group.get(group_name, []): key = (group_name, sample_name) diff --git a/src/metaboliteGrouping.py b/src/metaboliteGrouping.py index c28654f..0b2f54a 100644 --- a/src/metaboliteGrouping.py +++ b/src/metaboliteGrouping.py @@ -1,5 +1,7 @@ import numpy as np +# Relative/absolute tolerances used when comparing normalized (shape-only) abundance +# profiles in zero-variance fallback cases. NORMALIZED_PROFILE_RTOL = 1e-6 NORMALIZED_PROFILE_ATOL = 1e-9 @@ -90,6 +92,7 @@ def _connected_components(group_ids, adjacency): def _avg_similarity_to_group(node, group, similarities): + """Return the mean similarity from a node to all other members of a group.""" vals = [] for other in group: if node == other: @@ -101,6 +104,7 @@ def _avg_similarity_to_group(node, group, similarities): def _connection_rate_to_group(node, group, adjacency): + """Return the fraction of group members that are directly connected to node.""" if len(group) == 0: return 0.0 degree = sum(1 for neighbor in adjacency.get(node, set()) if neighbor in group) @@ -108,6 +112,13 @@ def _connection_rate_to_group(node, group, adjacency): def _split_component_by_dense_subclusters(component_ids, adjacency, similarities, min_connection_rate): + """Split a connected component into dense subclusters using connection-rate cores. + + A dense core contains nodes that satisfy the minimum connection-rate criterion + within the full component. Remaining nodes are then assigned to the best-matching + core (if their connection rate to that core is sufficient), otherwise kept as + separate groups. + """ if len(component_ids) <= 2: return [sorted(component_ids)] From 508a5f7a959fe43ccb3e468745f940e1ccd4b68c Mon Sep 17 00:00:00 2001 From: chrboku Date: Mon, 11 May 2026 15:37:07 +0200 Subject: [PATCH 15/27] Refactor UI components and enhance functionality - Removed unused UI files: groupEditor.ui and heteroAtomEditor.ui. - Added a new group box for integrated missed peaks in mainWindow.py with controls for max time difference and intensity cutoff. - Updated convolute results section in mainWindow.py to improve layout and organization of controls. - Implemented a custom delegate in statisticsTab.py to render selected rows in bold without altering their background color in the SelectedFeaturesTable. --- src/MExtract.py | 881 +++++++----- src/annotateResultMatrix.py | 2 +- src/mePyGuis/guis/FE_mainWindow.ui | 1186 ----------------- src/mePyGuis/guis/FTICRwindow.ui | 747 ----------- src/mePyGuis/guis/ModuleSelectionWindow.ui | 442 ------ src/mePyGuis/guis/ProcessingWizard.ui | 44 - src/mePyGuis/guis/SettingsWizard.ui | 563 -------- src/mePyGuis/guis/TSVLoaderEditor.ui | 309 ----- src/mePyGuis/guis/TracerEditor.ui | 473 ------- src/mePyGuis/guis/adductsEditor.ui | 396 ------ .../guis/calcIsotopeEnrichmentDialog.ui | 390 ------ src/mePyGuis/guis/combineResultsDialog.ui | 505 ------- src/mePyGuis/guis/groupEditor.ui | 468 ------- src/mePyGuis/guis/heteroAtomEditor.ui | 209 --- src/mePyGuis/mainWindow.py | 84 +- src/mePyGuis/statisticsTab.py | 20 +- 16 files changed, 596 insertions(+), 6123 deletions(-) delete mode 100644 src/mePyGuis/guis/FE_mainWindow.ui delete mode 100644 src/mePyGuis/guis/FTICRwindow.ui delete mode 100644 src/mePyGuis/guis/ModuleSelectionWindow.ui delete mode 100644 src/mePyGuis/guis/ProcessingWizard.ui delete mode 100644 src/mePyGuis/guis/SettingsWizard.ui delete mode 100644 src/mePyGuis/guis/TSVLoaderEditor.ui delete mode 100644 src/mePyGuis/guis/TracerEditor.ui delete mode 100644 src/mePyGuis/guis/adductsEditor.ui delete mode 100644 src/mePyGuis/guis/calcIsotopeEnrichmentDialog.ui delete mode 100644 src/mePyGuis/guis/combineResultsDialog.ui delete mode 100644 src/mePyGuis/guis/groupEditor.ui delete mode 100644 src/mePyGuis/guis/heteroAtomEditor.ui diff --git a/src/MExtract.py b/src/MExtract.py index 3fd155d..2677663 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -1597,7 +1597,7 @@ def loadGroupsResultsFile(self, groupsResFile): # show a dialog with a drop-down list asking the user to specify the table to load options = self.experimentResults.db_con.list_tables() - options = [opt for opt in options if opt not in ["Parameters", "__dTypes__", "2_StatColumns_Omitted", "5_Annotated_Compounds", "5_Annotated_SumFormulas"]][::-1] + options = [opt for opt in options if opt not in ["Parameters", "__dTypes__", "2_StatColumns_FalsePositives", "2_StatColumns_Omitted", "4_Convoluted_doublePeaks", "5_Annotated_Compounds", "5_Annotated_SumFormulas"]][::-1] mgsBox = QtWidgets.QMessageBox(self) mgsBox.setWindowTitle("Select results to load") @@ -3298,6 +3298,18 @@ def runProcess(self, dontSave=False, askStarting=True): errorCount = 0 + # Per-step tracking for the final summary dialog + _ST_SKIPPED_USER = "skipped_user" + _ST_OK = "ok" + _ST_ERROR = "error" + _ST_SKIPPED_PREV = "skipped_prev_error" + _step_status = {k: _ST_SKIPPED_USER for k in ("individual_files", "bracketing", "reintegration", "convolution", "annotation")} + _step_elapsed = {k: 0.0 for k in _step_status} + _step_details = {k: "" for k in _step_status} + _bracketing_failed = False + _reintegration_failed = False + _convolution_failed = False + cpus = min(cpu_count(), self.ui.cpuCores.value()) if self.terminateJobs: @@ -3319,6 +3331,7 @@ def runProcess(self, dontSave=False, askStarting=True): # process individual files if self.ui.processIndividualFiles.isChecked(): + _ind_step_start = time.time() logging.info("") logging.info("Processing %d individual LC-HRMS data files on %d CPU core(s).." % (len(files), min(len(files), cpus))) @@ -3561,6 +3574,11 @@ def runProcess(self, dontSave=False, askStarting=True): p.terminate() p.join() + _step_elapsed["individual_files"] = (time.time() - _ind_step_start) / 60.0 + if not self.terminateJobs: + _step_status["individual_files"] = _ST_OK + _step_details["individual_files"] = f"{len(files)} file(s): {len(files) - len(failedFiles)} finished, {len(failedFiles)} failed" + if self.terminateJobs: return @@ -3579,6 +3597,7 @@ def runProcess(self, dontSave=False, askStarting=True): logging.info("\n\n##############################################################") logging.info("Bracketing of individual LC-HRMS results..") + _bracketing_step_start = time.time() try: if True: # Group results @@ -3753,10 +3772,27 @@ def runProcess(self, dontSave=False, askStarting=True): grpOmit(excel_file, grpStats, sheet_name="2_StatColumns", new_sheet_name="2_StatColumns") logging.info("Statistic columns added (and feature pairs omitted)..") + _step_elapsed["bracketing"] = (time.time() - _bracketing_step_start) / 60.0 + _step_status["bracketing"] = _ST_OK + try: + import openpyxl as _opxl_br + + _wb_br = _opxl_br.load_workbook(excel_file, read_only=True) + _n_feat_br = max(0, _wb_br["2_StatColumns"].max_row - 1) if "2_StatColumns" in _wb_br.sheetnames else 0 + _wb_br.close() + _step_details["bracketing"] = f"{_n_feat_br} feature(s) detected" + except Exception: + _step_details["bracketing"] = "features detected" + except Exception as ex: traceback.print_exc() logging.error(str(traceback)) + _step_elapsed["bracketing"] = (time.time() - _bracketing_step_start) / 60.0 + _step_status["bracketing"] = _ST_ERROR + _step_details["bracketing"] = str(ex) + _bracketing_failed = True + QtWidgets.QMessageBox.warning( self, "MetExtract", @@ -3779,77 +3815,98 @@ def runProcess(self, dontSave=False, askStarting=True): # re-integrate missed peaks (run before grouping, if enabled) if self.ui.integratedMissedPeaks.isChecked(): - logging.info("\n\n##############################################################") - logging.info("Re-integrating of individual LC-HRMS results..") + if _bracketing_failed: + _step_status["reintegration"] = _ST_SKIPPED_PREV + logging.info("Skipping re-integration: bracketing step failed.") + else: + logging.info("\n\n##############################################################") + logging.info("Re-integrating of individual LC-HRMS results..") - pw = ProgressWrapper(min(len(files), cpus) + 1, showLog=False, parent=self) - pw.show() - pw.getCallingFunction()("text")("Integrating..") - pw.getCallingFunction()("header")("Integrating..") + _reintegration_step_start = time.time() + pw = ProgressWrapper(min(len(files), cpus) + 1, showLog=False, parent=self) + pw.show() + pw.getCallingFunction()("text")("Integrating..") + pw.getCallingFunction()("header")("Integrating..") - try: - # Reintegrate missed peaks in files - fDict = {} - for group in definedGroups: - for grp in natSort(group.files): - f = grp - f = f.replace("\\", "/") - fDict[f] = f[(f.rfind("/") + 1) : max(f.lower().rfind(".mzxml"), f.lower().rfind(".mzml"))] - - reIntegrateResultsFile( - excel_file, - "2_StatColumns", - "4_Reintegrated", - fDict, - addPeakArea=bool(self.ui.checkBox_expPeakArea.checkState() == QtCore.Qt.Checked), - addPeakAbundance=bool(self.ui.checkBox_expPeakApexIntensity.checkState() == QtCore.Qt.Checked), - addPeakSNR=bool(self.ui.checkBox_expPeakSNR.checkState() == QtCore.Qt.Checked), - ppm=self.ui.groupPpm.value(), - maxRTShift=self.ui.integrationMaxTimeDifference.value(), - scales=[ - self.ui.wavelet_minScale.value(), - self.ui.wavelet_maxScale.value(), - ], - reintegrateIntensityCutoff=self.ui.reintegrateIntensityCutoff.value(), - snrTH=self.ui.wavelet_SNRThreshold.value(), - smoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), - smoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), - smoothingWindowPolynom=self.ui.smoothingPolynom_spinner.value(), - positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), - negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), - pw=pw, - selfObj=self, - cpus=min(len(files), cpus), - start=start, - peak_filter_config=filter_config, - peak_picker=picker, - ) - convolution_input_sheet = "4_Reintegrated" - annotation_input_sheet = "4_Reintegrated" - - # Log time used for bracketing - elapsed = (time.time() - start) / 60.0 - hours = "" - if elapsed >= 60.0: - hours = "%d hours " % (elapsed // 60) - mins = "%.2f mins" % (elapsed % 60.0) - logging.info("Re-integrating finished (%s%s).." % (hours, mins)) - - except Exception as e: - traceback.print_exc() - logging.error(str(traceback)) + try: + # Reintegrate missed peaks in files + fDict = {} + for group in definedGroups: + for grp in natSort(group.files): + f = grp + f = f.replace("\\", "/") + fDict[f] = f[(f.rfind("/") + 1) : max(f.lower().rfind(".mzxml"), f.lower().rfind(".mzml"))] + + reIntegrateResultsFile( + excel_file, + "2_StatColumns", + "3_Reintegrated", + fDict, + addPeakArea=bool(self.ui.checkBox_expPeakArea.checkState() == QtCore.Qt.Checked), + addPeakAbundance=bool(self.ui.checkBox_expPeakApexIntensity.checkState() == QtCore.Qt.Checked), + addPeakSNR=bool(self.ui.checkBox_expPeakSNR.checkState() == QtCore.Qt.Checked), + ppm=self.ui.groupPpm.value(), + maxRTShift=self.ui.integrationMaxTimeDifference.value(), + scales=[ + self.ui.wavelet_minScale.value(), + self.ui.wavelet_maxScale.value(), + ], + reintegrateIntensityCutoff=self.ui.reintegrateIntensityCutoff.value(), + snrTH=self.ui.wavelet_SNRThreshold.value(), + smoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), + smoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), + smoothingWindowPolynom=self.ui.smoothingPolynom_spinner.value(), + positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), + negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), + pw=pw, + selfObj=self, + cpus=min(len(files), cpus), + start=start, + peak_filter_config=filter_config, + peak_picker=picker, + ) + convolution_input_sheet = "3_Reintegrated" + annotation_input_sheet = "3_Reintegrated" - QtWidgets.QMessageBox.warning( - self, - "MetExtract", - "Error during reintegrating files: '%s'" % str(e), - QtWidgets.QMessageBox.Ok, - ) - errorCount += 1 - finally: - pw.setSkipCallBack(True) - pw.hide() - logging.info("##############################################################") + _step_elapsed["reintegration"] = (time.time() - _reintegration_step_start) / 60.0 + _step_status["reintegration"] = _ST_OK + try: + import openpyxl as _opxl_ri + + _wb_ri = _opxl_ri.load_workbook(excel_file, read_only=True) + _n_feat_ri = max(0, _wb_ri["3_Reintegrated"].max_row - 1) if "3_Reintegrated" in _wb_ri.sheetnames else 0 + _wb_ri.close() + _step_details["reintegration"] = f"{_n_feat_ri} feature(s) re-integrated" + except Exception: + _step_details["reintegration"] = "re-integration complete" + + elapsed = (time.time() - start) / 60.0 + hours = "" + if elapsed >= 60.0: + hours = "%d hours " % (elapsed // 60) + mins = "%.2f mins" % (elapsed % 60.0) + logging.info("Re-integrating finished (%s%s).." % (hours, mins)) + + except Exception as e: + traceback.print_exc() + logging.error(str(traceback)) + + _step_elapsed["reintegration"] = (time.time() - _reintegration_step_start) / 60.0 + _step_status["reintegration"] = _ST_ERROR + _step_details["reintegration"] = str(e) + _reintegration_failed = True + + QtWidgets.QMessageBox.warning( + self, + "MetExtract", + "Error during reintegrating files: '%s'" % str(e), + QtWidgets.QMessageBox.Ok, + ) + errorCount += 1 + finally: + pw.setSkipCallBack(True) + pw.hide() + logging.info("##############################################################") if self.terminateJobs: pw.hide() @@ -3857,153 +3914,187 @@ def runProcess(self, dontSave=False, askStarting=True): # Calculate metabolite groups if self.ui.convoluteResults.isChecked(): - logging.info("\n\n##############################################################") - try: - pw = ProgressWrapper(1, parent=self) - pw.show() - pw.getCallingFunction()("text")("Convoluting feature pairs") - - findIsoPairsInstance = FindIsoPairs( - files[0], - exOperator=str(self.ui.exOperator_LineEdit.text()), - exExperimentID=str(self.ui.exExperimentID_LineEdit.text()), - exComments=str(self.ui.exComments_TextEdit.toPlainText()), - exExperimentName=str(self.ui.exExperimentName_LineEdit.text()), - metabolisationExperiment=self.labellingExperiment == TRACER, - labellingisotopeA=str(self.ui.isotopeAText.text()), - labellingisotopeB=str(self.ui.isotopeBText.text()), - xOffset=self.isotopeBmass - self.isotopeAmass, - useRatio=self.ui.useRatio.isChecked(), - minRatio=self.ui.minRatio.value(), - maxRatio=self.ui.maxRatio.value(), - useCIsotopePatternValidation=int(self.ui.useCValidation.checkState().value), - configuredTracer=self.configuredTracer, - intensityThreshold=self.ui.intensityThreshold.value(), - intensityCutoff=self.ui.intensityCutoff.value(), - startTime=self.ui.scanStartTime.value(), - stopTime=self.ui.scanEndTime.value(), - maxLoading=self.ui.maxLoading.value(), - xCounts=str(self.ui.xCountSearch.text()), - isotopicPatternCountLeft=self.ui.isotopePatternCountA.value(), - isotopicPatternCountRight=self.ui.isotopePatternCountB.value(), - lowAbundanceIsotopeCutoff=self.ui.isoAbundance.checkState() == QtCore.Qt.Checked, - purityN=self.ui.isotopicAbundanceA.value() / 100.0, - purityL=self.ui.isotopicAbundanceB.value() / 100.0, - intensityErrorN=self.ui.baseRange.value() / 100.0, - intensityErrorL=self.ui.isotopeRange.value() / 100.0, - scanIndexOffset=self.ui.scanIndexOffset.value(), - minSpectraCount=self.ui.minSpectraCount.value(), - clustPPM=self.ui.clustPPM.value(), - chromPeakPPM=self.ui.wavelet_EICppm.value(), - eicSmoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), - eicSmoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), - eicSmoothingPolynom=self.ui.smoothingPolynom_spinner.value(), - artificialMPshift_start=self.ui.spinBox_artificialMPshift_start.value(), - artificialMPshift_stop=self.ui.spinBox_artificialMPshift_stop.value(), - calcIsoRatioNative=self.ui.calcIsoRatioNative_spinBox.value(), - calcIsoRatioLabelled=self.ui.calcIsoRatioLabelled_spinBox.value(), - calcIsoRatioMoiety=self.ui.calcIsoRatioMoiety_spinBox.value(), - minCorrelationConnections=self.ui.minCorrelationConnections.value() / 100.0, - positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), - negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), - correctCCount=self.ui.correctcCount.checkState() == QtCore.Qt.Checked, - minCorrelation=self.ui.minCorrelation.value() / 100.0, - hAIntensityError=self.ui.hAIntensityError.value() / 100.0, - hAMinScans=self.ui.hAMinScans.value(), - adducts=self.adducts, - elements=self.elementsForNL, - heteroAtoms=self.heteroElements, - simplifyInSourceFragments=self.ui.checkBox_simplifyInSourceFragments.isChecked(), - lock=None, - queue=None, - pID=1, - meVersion="MetExtract (%s)" % MetExtractVersion, - peak_picker=picker, - peak_filter_config=filter_config, - ) + if _bracketing_failed or _reintegration_failed: + _step_status["convolution"] = _ST_SKIPPED_PREV + logging.info("Skipping convolution: a preceding step failed.") + else: + logging.info("\n\n##############################################################") + _convolution_step_start = time.time() + try: + pw = ProgressWrapper(1, parent=self) + pw.show() + pw.getCallingFunction()("text")("Convoluting feature pairs") - procProc = FuncProcess( - _target=calculateMetaboliteGroups, - file=excel_file, - toFile=excel_file, - sheet_name=convolution_input_sheet, - new_sheet_name="3_Convoluted", - groups=definedGroups, - eicPPM=self.ui.wavelet_EICppm.value(), - maxAnnotationTimeWindow=self.ui.maxAnnotationTimeWindow.value(), - minConnectionsInFiles=self.ui.metaboliteClusterMinConnections.value(), - minConnectionRate=self.ui.minConnectionRate.value() / 100.0, - minPeakCorrelation=self.ui.minCorrelation.value() / 100.0, - useAbundanceSimilarity=self.ui.useAbundanceSimilarityForConvolution.checkState() == QtCore.Qt.Checked, - abundanceSimilarityThreshold=self.ui.abundanceSimilarityThreshold.value() / 100.0, - useRatio=self.ui.useSILRatioForConvolution.checkState() == QtCore.Qt.Checked, - cpus=min(len(files), cpus), - ) + findIsoPairsInstance = FindIsoPairs( + files[0], + exOperator=str(self.ui.exOperator_LineEdit.text()), + exExperimentID=str(self.ui.exExperimentID_LineEdit.text()), + exComments=str(self.ui.exComments_TextEdit.toPlainText()), + exExperimentName=str(self.ui.exExperimentName_LineEdit.text()), + metabolisationExperiment=self.labellingExperiment == TRACER, + labellingisotopeA=str(self.ui.isotopeAText.text()), + labellingisotopeB=str(self.ui.isotopeBText.text()), + xOffset=self.isotopeBmass - self.isotopeAmass, + useRatio=self.ui.useRatio.isChecked(), + minRatio=self.ui.minRatio.value(), + maxRatio=self.ui.maxRatio.value(), + useCIsotopePatternValidation=int(self.ui.useCValidation.checkState().value), + configuredTracer=self.configuredTracer, + intensityThreshold=self.ui.intensityThreshold.value(), + intensityCutoff=self.ui.intensityCutoff.value(), + startTime=self.ui.scanStartTime.value(), + stopTime=self.ui.scanEndTime.value(), + maxLoading=self.ui.maxLoading.value(), + xCounts=str(self.ui.xCountSearch.text()), + isotopicPatternCountLeft=self.ui.isotopePatternCountA.value(), + isotopicPatternCountRight=self.ui.isotopePatternCountB.value(), + lowAbundanceIsotopeCutoff=self.ui.isoAbundance.checkState() == QtCore.Qt.Checked, + purityN=self.ui.isotopicAbundanceA.value() / 100.0, + purityL=self.ui.isotopicAbundanceB.value() / 100.0, + intensityErrorN=self.ui.baseRange.value() / 100.0, + intensityErrorL=self.ui.isotopeRange.value() / 100.0, + scanIndexOffset=self.ui.scanIndexOffset.value(), + minSpectraCount=self.ui.minSpectraCount.value(), + clustPPM=self.ui.clustPPM.value(), + chromPeakPPM=self.ui.wavelet_EICppm.value(), + eicSmoothingWindow=str(self.ui.eicSmoothingWindow.currentText()), + eicSmoothingWindowSize=self.ui.eicSmoothingWindowSize.value(), + eicSmoothingPolynom=self.ui.smoothingPolynom_spinner.value(), + artificialMPshift_start=self.ui.spinBox_artificialMPshift_start.value(), + artificialMPshift_stop=self.ui.spinBox_artificialMPshift_stop.value(), + calcIsoRatioNative=self.ui.calcIsoRatioNative_spinBox.value(), + calcIsoRatioLabelled=self.ui.calcIsoRatioLabelled_spinBox.value(), + calcIsoRatioMoiety=self.ui.calcIsoRatioMoiety_spinBox.value(), + minCorrelationConnections=self.ui.minCorrelationConnections.value() / 100.0, + positiveScanEvent=str(self.ui.positiveScanEvent.currentText()), + negativeScanEvent=str(self.ui.negativeScanEvent.currentText()), + correctCCount=self.ui.correctcCount.checkState() == QtCore.Qt.Checked, + minCorrelation=self.ui.minCorrelation.value() / 100.0, + hAIntensityError=self.ui.hAIntensityError.value() / 100.0, + hAMinScans=self.ui.hAMinScans.value(), + adducts=self.adducts, + elements=self.elementsForNL, + heteroAtoms=self.heteroElements, + simplifyInSourceFragments=self.ui.checkBox_simplifyInSourceFragments.isChecked(), + lock=None, + queue=None, + pID=1, + meVersion="MetExtract (%s)" % MetExtractVersion, + peak_picker=picker, + peak_filter_config=filter_config, + ) - # Create a shared Queue and Lock and attach to the FindIsoPairs instance - q = procProc.getQueue() - lock = Lock() - findIsoPairsInstance.queue = q - findIsoPairsInstance.lock = lock - - procProc.addKwd("pwMaxSet", q) - procProc.addKwd("pwValSet", q) - procProc.addKwd("pwTextSet", q) - procProc.addKwd("runIdentificationInstance", findIsoPairsInstance) - procProc.start() - - pw.setCloseCallback( - closeCallBack=CallBackMethod( - _target=interruptConvolutingOfFeaturePairs, - selfObj=self, - funcProc=procProc, - ).getRunMethod() - ) + procProc = FuncProcess( + _target=calculateMetaboliteGroups, + file=excel_file, + toFile=excel_file, + sheet_name=convolution_input_sheet, + new_sheet_name="4_Convoluted", + groups=definedGroups, + eicPPM=self.ui.wavelet_EICppm.value(), + maxAnnotationTimeWindow=self.ui.maxAnnotationTimeWindow.value(), + minConnectionsInFiles=self.ui.metaboliteClusterMinConnections.value(), + minConnectionRate=self.ui.minConnectionRate.value() / 100.0, + minPeakCorrelation=self.ui.minCorrelation.value() / 100.0, + useAbundanceSimilarity=self.ui.useAbundanceSimilarityForConvolution.checkState() == QtCore.Qt.Checked, + abundanceSimilarityThreshold=self.ui.abundanceSimilarityThreshold.value() / 100.0, + useRatio=self.ui.useSILRatioForConvolution.checkState() == QtCore.Qt.Checked, + cpus=min(len(files), cpus), + ) - # check for status updates - while procProc.isAlive(): - QtWidgets.QApplication.processEvents() - while not (procProc.getQueue().empty()): - mes = procProc.getQueue().get(block=False, timeout=1) - - # No idea why / where there are sometimes other objects than Bunch(mes, val), but they occur - if isinstance(mes, Bunch) and hasattr(mes, "mes") and (hasattr(mes, "val") or hasattr(mes, "text")): - pw.getCallingFunction()(mes.mes)(mes.val) - elif mes == (None, None): - ## I have no idea where this object comes from - pass - else: - logging.critical("UNKNONW OBJECT IN PROCESSING QUEUE: %s" % str(mes)) + # Create a shared Queue and Lock and attach to the FindIsoPairs instance + q = procProc.getQueue() + lock = Lock() + findIsoPairsInstance.queue = q + findIsoPairsInstance.lock = lock - time.sleep(0.5) + procProc.addKwd("pwMaxSet", q) + procProc.addKwd("pwValSet", q) + procProc.addKwd("pwTextSet", q) + procProc.addKwd("runIdentificationInstance", findIsoPairsInstance) + procProc.start() - # Log time used for bracketing - elapsed = (time.time() - start) / 60.0 - hours = "" - if elapsed >= 60.0: - hours = "%d hours " % (elapsed // 60) - mins = "%.2f mins" % (elapsed % 60.0) + pw.setCloseCallback( + closeCallBack=CallBackMethod( + _target=interruptConvolutingOfFeaturePairs, + selfObj=self, + funcProc=procProc, + ).getRunMethod() + ) - if self.terminateJobs: - return - else: - logging.info("Convoluting feature pairs finished (%s%s).." % (hours, mins)) - annotation_input_sheet = "3_Convoluted" + # check for status updates + while procProc.isAlive(): + QtWidgets.QApplication.processEvents() + while not (procProc.getQueue().empty()): + mes = procProc.getQueue().get(block=False, timeout=1) - except Exception as ex: - traceback.print_exc() - logging.error(str(traceback)) + # No idea why / where there are sometimes other objects than Bunch(mes, val), but they occur + if isinstance(mes, Bunch) and hasattr(mes, "mes") and (hasattr(mes, "val") or hasattr(mes, "text")): + pw.getCallingFunction()(mes.mes)(mes.val) + elif mes == (None, None): + ## I have no idea where this object comes from + pass + else: + logging.critical("UNKNONW OBJECT IN PROCESSING QUEUE: %s" % str(mes)) - QtWidgets.QMessageBox.warning( - self, - "MetExtract", - "Error during convolution of feature pairs: '%s'" % str(ex), - QtWidgets.QMessageBox.Ok, - ) - errorCount += 1 - finally: - pw.setSkipCallBack(True) - logging.info("##############################################################") + time.sleep(0.5) + + elapsed = (time.time() - start) / 60.0 + hours = "" + if elapsed >= 60.0: + hours = "%d hours " % (elapsed // 60) + mins = "%.2f mins" % (elapsed % 60.0) + + if self.terminateJobs: + return + else: + logging.info("Convoluting feature pairs finished (%s%s).." % (hours, mins)) + annotation_input_sheet = "4_Convoluted" + + _step_elapsed["convolution"] = (time.time() - _convolution_step_start) / 60.0 + _step_status["convolution"] = _ST_OK + try: + import openpyxl as _opxl_cv + + _wb_cv = _opxl_cv.load_workbook(excel_file, read_only=True) + if "4_Convoluted" in _wb_cv.sheetnames: + _ws_cv = _wb_cv["4_Convoluted"] + _headers_cv = [c.value for c in next(_ws_cv.iter_rows(max_row=1))] + if "OGroup" in _headers_cv: + _og_col = _headers_cv.index("OGroup") + _ogroups = {row[_og_col] for row in _ws_cv.iter_rows(min_row=2, values_only=True) if row[_og_col] is not None} + _n_groups = len(_ogroups) + _n_feats_cv = max(0, _ws_cv.max_row - 1) + else: + _n_groups = 0 + _n_feats_cv = max(0, _ws_cv.max_row - 1) + else: + _n_groups = 0 + _n_feats_cv = 0 + _wb_cv.close() + _step_details["convolution"] = f"{_n_feats_cv} feature(s) in {_n_groups} group(s)" + except Exception: + _step_details["convolution"] = "grouping complete" + + except Exception as ex: + traceback.print_exc() + logging.error(str(traceback)) + + _step_elapsed["convolution"] = (time.time() - _convolution_step_start) / 60.0 + _step_status["convolution"] = _ST_ERROR + _step_details["convolution"] = str(ex) + _convolution_failed = True + + QtWidgets.QMessageBox.warning( + self, + "MetExtract", + "Error during convolution of feature pairs: '%s'" % str(ex), + QtWidgets.QMessageBox.Ok, + ) + errorCount += 1 + finally: + pw.setSkipCallBack(True) + logging.info("##############################################################") pw.setSkipCallBack(True) pw.hide() @@ -4014,156 +4105,181 @@ def runProcess(self, dontSave=False, askStarting=True): annotationColumns = [] ## annotate results if self.ui.annotateMetabolites_CheckBox.isChecked(): - logging.info("\n\n##############################################################") - logging.info("Annotation of detected metabolites..") + if _bracketing_failed or _convolution_failed: + _step_status["annotation"] = _ST_SKIPPED_PREV + logging.info("Skipping annotation: a preceding step failed.") + else: + logging.info("\n\n##############################################################") + logging.info("Annotation of detected metabolites..") - useAdducts = [] - for adduct in self.adducts: - useAdducts.append( - [ - str(adduct.name), - adduct.mzoffset, - str(adduct.polarity), - adduct.charge, - adduct.mCount, - ] - ) + _annotation_step_start = time.time() + _annotation_error = False - pw = ProgressWrapper(1, parent=self) - pw.show() - pw.getCallingFunction()("text")("Annotation of detected metabolites") - pw.getCallingFunction()("header")("Annotating..") + useAdducts = [] + for adduct in self.adducts: + useAdducts.append( + [ + str(adduct.name), + adduct.mzoffset, + str(adduct.polarity), + adduct.charge, + adduct.mCount, + ] + ) - if self.ui.searchDB_checkBox.isChecked(): - pw.getCallingFunction()("text")("Searching hits in databases..") + pw = ProgressWrapper(1, parent=self) + pw.show() + pw.getCallingFunction()("text")("Annotation of detected metabolites") + pw.getCallingFunction()("header")("Annotating..") - # Collect database files - dbFiles = [] - for entryInd in range(self.ui.dbList_listView.model().rowCount()): - dbFile = str(self.ui.dbList_listView.model().item(entryInd, 0).data()) - dbFiles.append(dbFile) + if self.ui.searchDB_checkBox.isChecked(): + pw.getCallingFunction()("text")("Searching hits in databases..") - # Prepare parameters - useExactXn = str(self.ui.sumFormulasUseExactXn_ComboBox.currentText()) - if useExactXn.lower() == "plusminus": - useExactXn = "PlusMinus_%d" % (self.ui.sumFormulasPlusMinus_spinBox.value()) + # Collect database files + dbFiles = [] + for entryInd in range(self.ui.dbList_listView.model().rowCount()): + dbFile = str(self.ui.dbList_listView.model().item(entryInd, 0).data()) + dbFiles.append(dbFile) - try: - addedColumns = annotateResultMatrix.annotateWithDatabases( - file=excel_file, - sheet_name=annotation_input_sheet, - new_sheet_name="5_Annotated", - dbFiles=dbFiles, - useAdducts=useAdducts, - ppm=self.ui.annotationMaxPPM_doubleSpinBox.value(), - correctppmPosMode=self.ui.annotation_correctMassByPPMposMode.value(), - correctppmNegMode=self.ui.annotation_correctMassByPPMnegMode.value(), - rtError=self.ui.maxRTErrorInHits_spinnerBox.value(), - useRt=self.ui.checkRTInHits_checkBox.isChecked(), - checkXnInHits=useExactXn, - processedElement=getElementOfIsotope(str(self.ui.isotopeAText.text())), - pwMaxSet=pw.getCallingFunction()("max"), - pwValSet=pw.getCallingFunction()("value"), - ) - annotationColumns.extend(addedColumns) + # Prepare parameters + useExactXn = str(self.ui.sumFormulasUseExactXn_ComboBox.currentText()) + if useExactXn.lower() == "plusminus": + useExactXn = "PlusMinus_%d" % (self.ui.sumFormulasPlusMinus_spinBox.value()) - if False: - logging.info( - "## Database search: checkXn %s, ppm: %.5f, correctppm: pos.mode: %.5f / neg.mode: %.5f, Adducts: %s" - % ( - useExactXn, - self.ui.annotationMaxPPM_doubleSpinBox.value(), - self.ui.annotation_correctMassByPPMposMode.value(), - self.ui.annotation_correctMassByPPMnegMode.value(), - str(useAdducts), - ) + try: + addedColumns = annotateResultMatrix.annotateWithDatabases( + file=excel_file, + sheet_name=annotation_input_sheet, + new_sheet_name="5_Annotated", + dbFiles=dbFiles, + useAdducts=useAdducts, + ppm=self.ui.annotationMaxPPM_doubleSpinBox.value(), + correctppmPosMode=self.ui.annotation_correctMassByPPMposMode.value(), + correctppmNegMode=self.ui.annotation_correctMassByPPMnegMode.value(), + rtError=self.ui.maxRTErrorInHits_spinnerBox.value(), + useRt=self.ui.checkRTInHits_checkBox.isChecked(), + checkXnInHits=useExactXn, + processedElement=getElementOfIsotope(str(self.ui.isotopeAText.text())), + pwMaxSet=pw.getCallingFunction()("max"), + pwValSet=pw.getCallingFunction()("value"), ) + annotationColumns.extend(addedColumns) - except Exception as e: - traceback.print_exc() - logging.error(f"Error during database search: {e}") - QtWidgets.QMessageBox.warning( - self, - "MetExtract", - f"Error during database search annotation: {str(e)}", - QtWidgets.QMessageBox.Ok, - ) - errorCount += 1 - logging.info("##############################################################\n\n") + if False: + logging.info( + "## Database search: checkXn %s, ppm: %.5f, correctppm: pos.mode: %.5f / neg.mode: %.5f, Adducts: %s" + % ( + useExactXn, + self.ui.annotationMaxPPM_doubleSpinBox.value(), + self.ui.annotation_correctMassByPPMposMode.value(), + self.ui.annotation_correctMassByPPMnegMode.value(), + str(useAdducts), + ) + ) - if self.ui.generateSumFormulas_CheckBox.isChecked(): - pw.getCallingFunction()("text")("Generating sum formulas..") - pw.getCallingFunction()("value")(0) + except Exception as e: + traceback.print_exc() + logging.error(f"Error during database search: {e}") + QtWidgets.QMessageBox.warning( + self, + "MetExtract", + f"Error during database search annotation: {str(e)}", + QtWidgets.QMessageBox.Ok, + ) + _annotation_error = True + errorCount += 1 + logging.info("##############################################################\n\n") - try: - fT = formulaTools() - elemsMin = fT.parseFormula(str(self.ui.sumFormulasMinimumElements_lineEdit.text())) - elemsMax = fT.parseFormula(str(self.ui.sumFormulasMaximumElements_lineEdit.text())) - - useAtoms = [] - if getElementOfIsotope(str(self.ui.isotopeAText.text())) in elemsMax.keys(): - useAtoms.append(getElementOfIsotope(str(self.ui.isotopeAText.text()))) - - if "C" in elemsMax.keys() and "C" not in useAtoms: - useAtoms.append("C") - if "H" in elemsMax.keys() and "H" not in useAtoms: - useAtoms.append("H") - if "N" in elemsMax.keys() and "N" not in useAtoms: - useAtoms.append("N") - if "O" in elemsMax.keys() and "O" not in useAtoms: - useAtoms.append("O") - if "S" in elemsMax.keys() and "S" not in useAtoms: - useAtoms.append("S") - - for atom in elemsMax.keys(): - if atom not in useAtoms: - useAtoms.append(atom) - - atomsRange = [] - for atom in useAtoms: - minE = 0 - if atom in elemsMin.keys(): - minE = elemsMin[atom] - maxE = elemsMax[atom] - atomsRange.append([minE, maxE]) + if self.ui.generateSumFormulas_CheckBox.isChecked(): + pw.getCallingFunction()("text")("Generating sum formulas..") + pw.getCallingFunction()("value")(0) - useExactXn = str(self.ui.sumFormulasUseExactXn_ComboBox.currentText()) - if useExactXn.lower() == "plusminus": - useExactXn = "PlusMinus_%d" % (self.ui.sumFormulasPlusMinus_spinBox.value()) + try: + fT = formulaTools() + elemsMin = fT.parseFormula(str(self.ui.sumFormulasMinimumElements_lineEdit.text())) + elemsMax = fT.parseFormula(str(self.ui.sumFormulasMaximumElements_lineEdit.text())) + + useAtoms = [] + if getElementOfIsotope(str(self.ui.isotopeAText.text())) in elemsMax.keys(): + useAtoms.append(getElementOfIsotope(str(self.ui.isotopeAText.text()))) + + if "C" in elemsMax.keys() and "C" not in useAtoms: + useAtoms.append("C") + if "H" in elemsMax.keys() and "H" not in useAtoms: + useAtoms.append("H") + if "N" in elemsMax.keys() and "N" not in useAtoms: + useAtoms.append("N") + if "O" in elemsMax.keys() and "O" not in useAtoms: + useAtoms.append("O") + if "S" in elemsMax.keys() and "S" not in useAtoms: + useAtoms.append("S") + + for atom in elemsMax.keys(): + if atom not in useAtoms: + useAtoms.append(atom) + + atomsRange = [] + for atom in useAtoms: + minE = 0 + if atom in elemsMin.keys(): + minE = elemsMin[atom] + maxE = elemsMax[atom] + atomsRange.append([minE, maxE]) + + useExactXn = str(self.ui.sumFormulasUseExactXn_ComboBox.currentText()) + if useExactXn.lower() == "plusminus": + useExactXn = "PlusMinus_%d" % (self.ui.sumFormulasPlusMinus_spinBox.value()) + + addedColumns = annotateResultMatrix.annotateWithSumFormulas( + file=excel_file, + sheet_name="5_Annotated", + useAtoms=useAtoms, + atomsRange=atomsRange, + processedElement=getElementOfIsotope(str(self.ui.isotopeAText.text())), + useExactXn=useExactXn, + ppm=self.ui.annotationMaxPPM_doubleSpinBox.value(), + ppmCorrectionPosMode=self.ui.annotation_correctMassByPPMposMode.value(), + ppmCorrectionNegMode=self.ui.annotation_correctMassByPPMnegMode.value(), + useAdducts=useAdducts, + pwMaxSet=pw.getCallingFunction()("max"), + pwValSet=pw.getCallingFunction()("value"), + nCores=min(len(files), cpus), + ) + annotationColumns.extend(addedColumns) - addedColumns = annotateResultMatrix.annotateWithSumFormulas( - file=excel_file, - sheet_name="5_Annotated", - useAtoms=useAtoms, - atomsRange=atomsRange, - processedElement=getElementOfIsotope(str(self.ui.isotopeAText.text())), - useExactXn=useExactXn, - ppm=self.ui.annotationMaxPPM_doubleSpinBox.value(), - ppmCorrectionPosMode=self.ui.annotation_correctMassByPPMposMode.value(), - ppmCorrectionNegMode=self.ui.annotation_correctMassByPPMnegMode.value(), - useAdducts=useAdducts, - pwMaxSet=pw.getCallingFunction()("max"), - pwValSet=pw.getCallingFunction()("value"), - nCores=min(len(files), cpus), - ) - annotationColumns.extend(addedColumns) + except Exception as e: + traceback.print_exc() + logging.error(f"Error during sum formula generation: {e}") + QtWidgets.QMessageBox.warning( + self, + "MetExtract", + f"Error during sum formula generation: {str(e)}", + QtWidgets.QMessageBox.Ok, + ) + _annotation_error = True + errorCount += 1 + logging.info("\n\n##############################################################") - except Exception as e: - traceback.print_exc() - logging.error(f"Error during sum formula generation: {e}") - QtWidgets.QMessageBox.warning( - self, - "MetExtract", - f"Error during sum formula generation: {str(e)}", - QtWidgets.QMessageBox.Ok, - ) - errorCount += 1 - logging.info("\n\n##############################################################") + print("\n\n") + pw.hide() - print("\n\n") - pw.hide() + _step_elapsed["annotation"] = (time.time() - _annotation_step_start) / 60.0 + if _annotation_error: + _step_status["annotation"] = _ST_ERROR + _step_details["annotation"] = "annotation encountered errors" + else: + _step_status["annotation"] = _ST_OK + try: + import openpyxl as _opxl_an - logging.info("##############################################################") + _wb_an = _opxl_an.load_workbook(excel_file, read_only=True) + _n_feat_an = max(0, _wb_an["5_Annotated"].max_row - 1) if "5_Annotated" in _wb_an.sheetnames else 0 + _wb_an.close() + _step_details["annotation"] = f"{_n_feat_an} feature(s) annotated" + except Exception: + _step_details["annotation"] = "annotation complete" + + logging.info("##############################################################") ## Process MSMS info if self.ui.generateMSMSInfo_CheckBox.isChecked(): @@ -4229,8 +4345,9 @@ def runProcess(self, dontSave=False, askStarting=True): self.updateLCMSSampleSettings() - # Log time used for bracketing - elapsed = (time.time() - overallStart) / 60.0 + # Log overall time + _overall_elapsed = (time.time() - overallStart) / 60.0 + elapsed = _overall_elapsed hours = "" if elapsed >= 60.0: hours = "%d hours " % (elapsed // 60) @@ -4246,12 +4363,72 @@ def runProcess(self, dontSave=False, askStarting=True): notification_msg = "Processing finished with %d errors in %s%s" % (errorCount, hours, mins) self._send_desktop_notification("MetExtract II", notification_msg) - QtWidgets.QMessageBox.information( - self, - "MetExtract II", - "Processing finished %sin %s%s" % ("(%d errors) " % errorCount if errorCount > 0 else "", hours, mins), - QtWidgets.QMessageBox.Ok, - ) + # Build and show the processing summary dialog + def _fmt_elapsed(m): + if m <= 0: + return "—" + if m < 1: + return f"{m * 60:.1f} sec" + if m < 60: + return f"{m:.2f} min" + return f"{int(m // 60)}h {m % 60:.1f} min" + + _step_names = { + "individual_files": "1. Individual file processing", + "bracketing": "2. Bracketing", + "reintegration": "3. Re-integration", + "convolution": "4. Feature grouping", + "annotation": "5. Annotation", + } + _status_style = { + _ST_OK: ("olivedrab", "OK"), + _ST_ERROR: ("firebrick", "Error"), + _ST_SKIPPED_USER: ("gray", "Skipped (not enabled)"), + _ST_SKIPPED_PREV: ("darkorange", "Skipped (previous step failed)"), + } + + rows_html = "" + for _key in ("individual_files", "bracketing", "reintegration", "convolution", "annotation"): + _color, _label = _status_style.get(_step_status[_key], ("gray", _step_status[_key])) + _det = _step_details[_key] or "—" + _dur = _fmt_elapsed(_step_elapsed[_key]) + rows_html += f"{_step_names[_key]}{_label}{_det}{_dur}" + + _summary_html = f""" +

Processing Summary

+ + + + + + + + {rows_html} +
StepStatusDetailsDuration
+

+ Total time: {_fmt_elapsed(_overall_elapsed)} +

+

Results can be viewed:

+
    +
  • In this application via the Experimental results tab
  • +
  • In the Excel file at:
    {excel_file}
  • +
+""" + + _dlg = QtWidgets.QDialog(self) + _dlg.setWindowTitle("MetExtract II – Processing Summary") + _dlg.setMinimumSize(800, 420) + _dlg_layout = QtWidgets.QVBoxLayout(_dlg) + _browser = QtWidgets.QTextBrowser(_dlg) + _browser.setHtml(_summary_html) + _browser.setOpenExternalLinks(False) + _dlg_layout.addWidget(_browser) + _btn_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok, parent=_dlg) + _btn_box.accepted.connect(_dlg.accept) + _dlg_layout.addWidget(_btn_box) + _dlg.exec() + self.loadGroupsResultsFile(str(self.ui.groupsSave.text())) def showResultsSummary(self): @@ -4399,7 +4576,7 @@ def showResultsSummary(self): res_db = PolarsDB(resFileFull, format="xlsx", load_all_tables=True) convoluted_sheet = None - for candidate in ("3_Convoluted", "2_StatColumns"): + for candidate in ("3_Reintegrated", "2_StatColumns"): if candidate in res_db.tables: convoluted_sheet = candidate break @@ -8576,7 +8753,7 @@ def _make_item(text): # Use cached DataFrame to avoid re-reading the Excel file on every feature selection change results_df = getattr(self.experimentResults, "_peak_details_df", None) if results_df is None: - sheet_candidates = ["4_Reintegrated", "3_Convoluted", "1_Bracketed"] + sheet_candidates = ["4_Convoluted", "3_Reintegrated", "1_Bracketed"] for sheet_name in sheet_candidates: try: tbl = self.experimentResults.db_con.get_table(sheet_name) diff --git a/src/annotateResultMatrix.py b/src/annotateResultMatrix.py index 104ec0f..48361ec 100644 --- a/src/annotateResultMatrix.py +++ b/src/annotateResultMatrix.py @@ -167,7 +167,7 @@ def annotateWithDatabases( Args: file: Path to the results file (PolarsDB format) - sheet_name: Name of the sheet to read from (e.g., "4_Reintegrated") + sheet_name: Name of the sheet to read from (e.g., "3_Reintegrated") new_sheet_name: Name of the sheet to write to (e.g., "6_Annotated") dbFiles: List of database file paths useAdducts: List of adduct definitions [[name, mzoffset, polarity, charge, mCount], ...] diff --git a/src/mePyGuis/guis/FE_mainWindow.ui b/src/mePyGuis/guis/FE_mainWindow.ui deleted file mode 100644 index a8b612f..0000000 --- a/src/mePyGuis/guis/FE_mainWindow.ui +++ /dev/null @@ -1,1186 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1391 - 840 - - - - true - - - MainWindow - - - QTabWidget::Triangular - - - - - - - true - - - QTabWidget::West - - - 1 - - - - Input - - - - - - - - - - - 200 - 0 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p align="justify">Please specify the performed LC-HRMSMS mesurements of M and M'. Specify each target in single row using the button 'Add MS/MS target(s)'. Load an LC-HRMSMS measurement multiple times if it contains more than one target. Select the appropriate scan events for the native and the labeled metabolite ions.</p></body></html> - - - true - - - - - - - font: 10pt; - - - <html><head/><body><p align="right">Define MS/MS targets</p></body></html> - - - - - - - Qt::Vertical - - - QSizePolicy::Maximum - - - - 0 - 5 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - Save compilation - - - - - - - Load compilation - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Add MS/MS target(s) - - - - - - - Delete current MS/MS target - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Maximum - - - - 3 - 0 - - - - - - - - <html><head/><body><p align="right">Define/Edit group</p></body></html> - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - - - false - - - QAbstractItemView::MultiSelection - - - QAbstractItemView::SelectRows - - - - - - - - - Configure adducts - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - Process - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 3 - 0 - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Maximum - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - font: 10pt; - - - <html><head/><body><p align="right">Processing settings</p></body></html> - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the settings for processing the defined MS/MS targets. First, the two MS/MS spectra will be insepcted for corresponding native and U-<span style=" vertical-align:super;">13</span>C-labeled peaks. These peaks will be annotated with putative sum formulas using the determined number of carbon atoms. </p></body></html> - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 20 - 40 - - - - - - - - - - Qt::Vertical - - - - - - - - - font: 10pt; - - - <html><head/><body><p align="right">Run tasks</p></body></html> - - - - - - - - - - - CPU cores - - - - - - - 1 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Keep one core unused - - - - - - - Start - - - - - - - - - - - Scan selection - - - - - - Full scan EIC ppm - - - - - - - 5.000000000000000 - - - - - - - Intensity threshold - - - - - - - 0 - - - 999999999.000000000000000 - - - 10000.000000000000000 - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 0 - 0 - - - - - - - - - - - Peak matching - - - - - - Matching max. PPM error - - - - - - - Max. relative intensity error - - - - - - - 9999.000000000000000 - - - 30.000000000000000 - - - - - - - 100.000000000000000 - - - 20.000000000000000 - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 0 - 0 - - - - - - - - - - - - - Scan pre-processing - - - - - - Min. scaled peak intensity [%] - - - - - - - 1 - - - 0.000000000000000 - - - 0.500000000000000 - - - - - - - false - - - - 0 - 0 - - - - Scale to precursor mz - - - true - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Ignored - - - - 0 - 20 - - - - - - - - - - Fragment annotation - - - - - - - - Used elements - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 0 - 0 - - - - - - - - - - Max. ppm error - - - - - - - Allow additional Cn (e.g. biotransformation products) - - - - - - - - - 1 - - - 1000.000000000000000 - - - 5.000000000000000 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 0 - 0 - - - - - - - - - - - - Min. number of labeling atoms - - - - - - - - - - - - Use zero labeling atoms (for biotransformation products) - - - - - - - - - - Parent annotation - - - - - - Apply parent-fragment consistency rule - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 20 - 40 - - - - - - - - - - - Save results - - - - - - Save as TSV - - - true - - - - - - - Save as PDF - - - - - - - Export as SIRIUS 4.0 MS format - - - - - - - - - - MassBank search - - - - - - 1 - - - 1.000000000000000 - - - 5.000000000000000 - - - - - - - 0 - - - 1.000000000000000 - - - - - - - m/z error (ppm) - - - - - - - Minimal relative abundance - - - - - - - Hits to load - - - - - - - 999 - - - 10 - - - - - - - Minimal score - - - - - - - 1.000000000000000 - - - 0.010000000000000 - - - - - - - - - - - - - 0 - 0 - - - - Qt::Vertical - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - - Sample results - - - - - - - - - - - 0 - 0 - - - - Processed file - - - - - - - - 150 - 0 - - - - - - - - Open externally - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - 0 - 0 - - - - - Target name - - - - - MZ - - - - - Cn - - - - - Sum formula - - - - - Charge - - - - - Scan num native isotopolog - - - - - Scan num labeled isotopolog - - - - - Full scan event - - - - - MS2 scan event native - - - - - MS2 scan event labeled isotopolog - - - - - - - - - - - - Plot labeled MS2-scan - - - true - - - - - - - Qt::Horizontal - - - - 0 - 0 - - - - - - - - Plot cleaned scan(s) - - - true - - - - - - - Plot native MS2-scan - - - true - - - - - - - - - - - - 0 - 0 - - - - - 3 - 0 - - - - - - - - - 1 - 0 - - - - - - - - - - - - - - - - 0 - 0 - 1391 - 21 - - - - - File - - - - - - - - - Help - - - - - - - - - - - Load Settings - - - - - Save Settings - - - - - Exit - - - - - Help - - - - - About - - - - - - diff --git a/src/mePyGuis/guis/FTICRwindow.ui b/src/mePyGuis/guis/FTICRwindow.ui deleted file mode 100644 index 2e4a886..0000000 --- a/src/mePyGuis/guis/FTICRwindow.ui +++ /dev/null @@ -1,747 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1369 - 907 - - - - true - - - MetExtract II - FT-ICR module - - - - - - - - - - 1 - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 32 - - - - Load samples (.tsv format) - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - <html><head/><body><p align="right"> - -<span style=" font-size:18pt;">Load sample files</span></p> - -<p align="right">First load the sample files in the tab-separated-values (.tsv) format.<br> -Two columns, one specifying the m/z value and one the observed intensity, must be present.<br> -The respective columns can be specified later in a separate dialog.</p> - -</body></html> - - - - - - - - 32 - - - - Load samples (.mzXML format) - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - <html><head/><body><p align="right"> - -<span style=" font-size:18pt;">Processing parameters</span></p> - -<p align="right">Please specify all necessary processing parameters for the loaded FT-ICR MS scans.</p> - -</body></html> - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Process samples - - - - - - Process - - - - - - - - 0 - 0 - - - - Intensity threshold - - - - - - - Match ppm - - - - - - - 100000000000.000000000000000 - - - 1000000.000000000000000 - - - - - - - 2.000000000000000 - - - - - - - Bracketing ppm - - - - - - - M+1 and M'-1 must be present - - - true - - - - - - - Calibrate with average MZ error based -on generated, unique sum formulas - - - true - - - - - - - 0.500000000000000 - - - - - - - Labeling enrichment - - - - - - <sup>13</sup>C-labeled - - - - - - - Native - - - - - - - 4 - - - 0.001000000000000 - - - 0.989300000000000 - - - - - - - 4 - - - 0.001000000000000 - - - 0.990000000000000 - - - - - - - Maximum deviation +- - - - - - - - 0.150000000000000 - - - - - - - - - - Sum formula generation - - - - - - Elements for sum formula generation - - - - - - - 0 - 0 - - - - Min - - - - - - - - 0 - 0 - - - - CH - - - - - - - - 0 - 0 - - - - Max - - - - - - - - 0 - 0 - - - - CH1000N10O100S10P10 - - - - - - - - - - Used adducts - - - - - - [M+K-2H]- - - - - - - - [M+Na-2H]- - - - - - - - [M+Cl]- - - - true - - - - - - - [M-H]- - - - true - - - - - - - [M+FA-H]- - - - - - - - [M+Br]- - - - - - - - - - - Use Seven Golden Rules (Kind et al. 2007) - - - true - - - - - - - - 0 - 0 - - - - Annotation ppm - - - - - - - 0.500000000000000 - - - - - - - Sumformula databases - - - - - - - 0 - 0 - - - - - 0 - 40 - - - - - - - - Add database - - - - - - - - - - - - - - 0 - 0 - - - - Carbon atoms to search for - - - - - - - 3-60 - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - 0 - 0 - - - - Results - - - - - - - 0 - 0 - - - - - - - - - - - - - Loaded samples and results - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - QAbstractItemView::MultiSelection - - - - - - - - 0 - 0 - - - - Samples - - - - - - - - 0 - 0 - - - - Results - - - - - - - - 0 - 0 - - - - - 350 - 0 - - - - QAbstractItemView::DropOnly - - - - MZ - - - - - Cn - - - - - Sumformula - - - - - - - - - - - - - <html><head/><body><p align="right"> - -<span style=" font-size:18pt;">Detected signal pairs</span></p> - -<p align="right">The data processing results can be quickly visualized here.</p> - -</body></html> - - - - - - - - - - - - - 0 - 0 - 1369 - 21 - - - - - File - - - - - - - - - - - Load samples - - - - - Save results - - - - - Exit - - - - - Load results - - - - - New processing - - - - - - diff --git a/src/mePyGuis/guis/ModuleSelectionWindow.ui b/src/mePyGuis/guis/ModuleSelectionWindow.ui deleted file mode 100644 index 8e689d3..0000000 --- a/src/mePyGuis/guis/ModuleSelectionWindow.ui +++ /dev/null @@ -1,442 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 638 - 698 - - - - MainWindow - - - - :/MEIcon/resources/MEIcon.ico:/MEIcon/resources/MEIcon.ico - - - background-color: rgb(255, 255, 255); - - - - - - - - - Combine results -(AllExtract and TracExtract) - - - - - - - - - 90 - 90 - - - - - 90 - 90 - - - - background-image: url(:/TracExtract/resources/TracExtract.png); - - - - - - true - - - - - - - - 90 - 90 - - - - - 90 - 90 - - - - background-image: url(:/FragExtract/resources/FragExtract.png); - - - - - - true - - - - - - - FragExtract -(LC-HRMS) - - - - - - - - Documentation - - - - - - - QFrame::Sunken - - - Qt::Horizontal - - - - - - - - - - AllExtract -(LC-HRMS) - - - - - - - QFrame::Sunken - - - Qt::Horizontal - - - - - - - - 90 - 90 - - - - - 90 - 90 - - - - background-image: url(:/Documentation/resources/Documentation.png); - - - - - - true - - - - - - - TracExtract -(LC-HRMS) - - - - - - - - 90 - 90 - - - - - 90 - 90 - - - - background-image: url(:/AllExtract/resources/AllExtract.png); - - - - - - true - - - - - - - Qt::Horizontal - - - - - - - - 90 - 90 - - - - background-image: url(:/combineResults/resources/combineResults.png); - - - - - - true - - - - - - - Qt::Horizontal - - - - - - - QFrame::Sunken - - - Qt::Horizontal - - - - - - - - 90 - 90 - - - - background-image: url(:/FTICRExtract/resources/FTICRExtract.png); - - - - - - true - - - - - - - FTICRExtract -(FT-ICR-MS) - - - - - - - - Qt::Horizontal - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - color: slategrey; - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - - 80 - 80 - - - - image: url(:/MEIcon_Large/resources/MEIcon_Large.png); - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - - 0 - 0 - - - - - - - - - - - - 0 - 0 - 638 - 21 - - - - - Tools - - - - - - - - Calculate isotopic enrichment - - - - - - - - diff --git a/src/mePyGuis/guis/ProcessingWizard.ui b/src/mePyGuis/guis/ProcessingWizard.ui deleted file mode 100644 index c08e447..0000000 --- a/src/mePyGuis/guis/ProcessingWizard.ui +++ /dev/null @@ -1,44 +0,0 @@ - - - Wizard - - - - 0 - 0 - 674 - 536 - - - - Wizard - - - - - - QWizard::ModernStyle - - - - - - - - - 10 - - - 10 - - - true - - - true - - - true - - - diff --git a/src/mePyGuis/guis/SettingsWizard.ui b/src/mePyGuis/guis/SettingsWizard.ui deleted file mode 100644 index 13e2a8b..0000000 --- a/src/mePyGuis/guis/SettingsWizard.ui +++ /dev/null @@ -1,563 +0,0 @@ - - - Wizard - - - - 0 - 0 - 591 - 469 - - - - Wizard - - - QWizard::ModernStyle - - - QWizard::HaveCustomButton1 - - - - New SIL-Experiment - - - - - - <html><head/><body><p>Define a new stable isotope labelling assisted metabolomics experiment</p><p>Two types of experiments are supported: </p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Full metabolome labelling experiment (FML)</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Tracer meabolisation study (TMS)</li></ul><p>In a FML experiment the entire metabolome of a biological system must be availalbe as a highly stable isotope enriched form (e.g.: A fungi that is grown on <span style=" vertical-align:super;">13</span>C-glucose). </p><p>In a TMS experiment only a certain substance is labelled. If the studies stubstance is exogenous to the biological system, it needs to be provided as a non-labelled and a highly isotope enriched form (e.g. detoxification of a <span style=" vertical-align:super;">13</span>C-toxin in a plant). If, however, the substance of interst is endogenous to the studied biological system, then the substance must be provided as a highly isotope enriched form but does not need to be provided as a non-labelled form if it is available in the biological system in high enough abundance (e.g. <span style=" vertical-align:super;">13</span>C<span style=" vertical-align:sub;">6</span> phenylalanine). In either case only those metabolites, which incorporate the tracer, will be extracted.</p><p>Currently only such substances, that result in a unique and well separated isotope pattern, are supported. Using <span style=" vertical-align:super;">13</span>C-glucose as a tracer, which is onyl partly incooperated into metabolites, is not supported</p></body></html> - - - true - - - - - - - - - Experiment type: - - - - - - - Full Metabolome Labelling - - - true - - - - - - - Tracer Mextabolisation - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Tracer Metabolisation Experiment - - - - - - - - Configure Tracers - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Full Metabolome Labelling - - - - - - - - GroupBox - - - - - - 5 - - - 1.000000000000000 - - - 0.995000000000000 - - - - - - - Enrichment is - - - - - - - - 0 - 0 - - - - 13C - - - - - - - mass is - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Natural isotope - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - 5 - - - 1.000000000000000 - - - 0.010000000000000 - - - 0.990000000000000 - - - - - - - - 0 - 0 - - - - 12C - - - 6 - - - - - - - mass is - - - - - - - Enrichment is - - - - - - - - - - Use carbon validation - - - - - - - - - - M/Z Picking - - - - - - - - Consider isotopologue abundance - - - - - - - Maximum charge - - - - - - - 1 - - - 999.000000000000000 - - - 37.000000000000000 - - - - - - - <html><head/><body><p align="center"> ≥</p></body></html> - - - - - - - 1 - - - 4 - - - 2 - - - - - - - 999999999 - - - - - - - Start - - - - - - - Intensity cutoff (import) - - - - - - - 999999999 - - - 5000 - - - - - - - End - - - - - - - Number of atoms - - - - - - - 3 - - - - - - - 70 - - - - - - - Scan range [min] - - - - - - - 1 - - - - - - - 1 - - - 999.000000000000000 - - - 3.000000000000000 - - - - - - - min: - - - - - - - max: - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - 1 - - - 3 - - - 2 - - - - - - - 1 - - - 3 - - - 2 - - - - - - - Isotopologue ratio error (±) - - - - - - - - - - - - - - Intensity threshold - - - - - - - Isotopic pattern count (M) - - - - - - - Isotopic pattern count (M') - - - - - - - Mass deviation (ppm) - - - - - - - <html><head/><body><p align="center">≤ </p></body></html> - - - - - - - TextLabel - - - - - - - TextLabel - - - - - - - 0.000000000000000 - - - 1.000000000000000 - - - 0.100000000000000 - - - 0.100000000000000 - - - - - - - 0.000000000000000 - - - 1.000000000000000 - - - 0.100000000000000 - - - 0.100000000000000 - - - - - - - - - - Chromatographic separation - - - - - - - diff --git a/src/mePyGuis/guis/TSVLoaderEditor.ui b/src/mePyGuis/guis/TSVLoaderEditor.ui deleted file mode 100644 index a017cac..0000000 --- a/src/mePyGuis/guis/TSVLoaderEditor.ui +++ /dev/null @@ -1,309 +0,0 @@ - - - Dialog - - - - 0 - 0 - 1036 - 266 - - - - Dialog - - - - - - - - - - - Qt::Vertical - - - - - - - - - font: 10pt; - - - Actions - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 0 - 0 - - - - - - - - - - - - font: 10pt; - - - some title - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 15 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>some description</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - - 200 - 0 - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - - - - - - - Load table - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Discard - - - - - - - Accept - - - - - - - - - - - - Qt::ScrollBarAlwaysOn - - - true - - - - - 0 - 0 - 762 - 195 - - - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - - - - 0 - 0 - - - - <html><head/><body><p><span style=" text-decoration: underline;">ID</span> map to</p></body></html> - - - - - - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - - - - - - - - - - diff --git a/src/mePyGuis/guis/TracerEditor.ui b/src/mePyGuis/guis/TracerEditor.ui deleted file mode 100644 index d1140a3..0000000 --- a/src/mePyGuis/guis/TracerEditor.ui +++ /dev/null @@ -1,473 +0,0 @@ - - - Dialog - - - - 0 - 0 - 759 - 506 - - - - Dialog - - - - - - - - - - - Qt::Vertical - - - - - - - - - font: 10pt; - - - Actions - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Preferred - - - - 0 - 0 - - - - - - - - - - Qt::Horizontal - - - - - - - - - font: 10pt; - - - Tracer - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 15 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the tracer added to the experiment</p><p><br/></p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - - 200 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Discard - - - - - - - Accept - - - - - - - - - - - Name - - - - - - - DON - - - - - - - Atom count - - - - - - - 1 - - - 15 - - - - - - - Native isotopolog - - - - - - - Native isotopolog enrichment - - - - - - - Labeled isotopolog - - - - - - - Labeled isotopolog enrichment - - - - - - - % - - - 98.930000000000007 - - - - - - - % - - - 99.500000000000000 - - - - - - - 12C - - - - - - - 13C - - - - - - - Virtually allowed ratio for exogenous tracers only - - - - - - Amount native compound - - - - - - - Amount labeled compound - - - - - - - = - - - 1.000000000000000 - - - 50.000000000000000 - - - - - - - = - - - 50.000000000000000 - - - - - - - Minimum ratio deviation relative to theoretical signal ratio - - - - - - - Maximum ratio deviation relative to theoretical signal ratio - - - - - - - - - - % - - - 100000000.000000000000000 - - - 160.000000000000000 - - - - - - - Allowed ratios in the LC-HRMS data calculated from the isotopic enrichments and the spiked amounts: - - - true - - - - - - - TextLabel - - - - - - - - - - % - - - 62.500000000000000 - - - - - - - Theoretical signal ratio - - - - - - - - - - - - - - - - - - Exogenous tracer (i.e. check ratio of native and labeled tracer form) - - - - - - - Note: Activate the ratio check for exogenous tracer compounds and deactivate it for endogenous tracer compounds - - - true - - - - - - - - - - - tName - tAtomCount - tNativeIsotope - tNativeEnrichment - tLabeledIsotope - tLabeledEnrichment - tMinRatio - tMaxRatio - acceptButton - discardButton - - - - diff --git a/src/mePyGuis/guis/adductsEditor.ui b/src/mePyGuis/guis/adductsEditor.ui deleted file mode 100644 index 047a62b..0000000 --- a/src/mePyGuis/guis/adductsEditor.ui +++ /dev/null @@ -1,396 +0,0 @@ - - - Dialog - - - - 0 - 0 - 819 - 610 - - - - Relationship configuration - - - - - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - - - font: 10pt; - - - Adducts - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the adducts you want to use during the non-targeted feature grouping. Each row in the table represents one adduct</p><p>The first column is the name of the adduct<br/>The second column specifies the adducts m/z difference to a neutral molecule<br/>The third column specifies in which ionisation mode the adduct may occour. Valid values are + and -</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - - 200 - 0 - - - - - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - - - font: 10pt; - - - Actions - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - - - Load defaults - - - - - - - Load - - - - - - - Save - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Discard - - - - - - - Accept - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - Qt::Vertical - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - - - font: 10pt; - - - Neutral loss (elements) - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the elements you want to use for the non-targeted feature group annotation. Each row represents one element</p><p>The first column specifies the chemical symbol of the element<br/>The second column specifies the neutral weight of the most abundant isotope of the elment<br/>The third column specifies the number of valenz electron this element has<br/>The fourth and fifth column specify the minimal and maximal number this element can occour in non-targetd group annotation</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - - 200 - 0 - - - - - - - - - - - - - - - - diff --git a/src/mePyGuis/guis/calcIsotopeEnrichmentDialog.ui b/src/mePyGuis/guis/calcIsotopeEnrichmentDialog.ui deleted file mode 100644 index 01f1605..0000000 --- a/src/mePyGuis/guis/calcIsotopeEnrichmentDialog.ui +++ /dev/null @@ -1,390 +0,0 @@ - - - Dialog - - - - 0 - 0 - 1195 - 1109 - - - - Dialog - - - background-color: white; - - - - - 950 - 1060 - 221 - 32 - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - 19 - 25 - 1161 - 568 - - - - - 0 - 0 - - - - background-image: url(:/EnrichmentDialog/resources/EnrichmentDialog.png) - - - - - - - - - 20 - 600 - 1151 - 451 - - - - - #1 - - - - - #2 - - - - - #3 - - - - - #4 - - - - - #5 - - - - - #6 - - - - - #7 - - - - - #8 - - - - - #9 - - - - - #10 - - - - - #11 - - - - - #12 - - - - - #13 - - - - - #14 - - - - - #15 - - - - - #16 - - - - - #17 - - - - - #18 - - - - - #19 - - - - - #20 - - - - - #21 - - - - - #22 - - - - - #23 - - - - - #24 - - - - - #25 - - - - - #26 - - - - - #27 - - - - - #28 - - - - - #29 - - - - - #30 - - - - - #31 - - - - - #32 - - - - - #33 - - - - - #34 - - - - - #35 - - - - - #36 - - - - - #37 - - - - - #38 - - - - - #39 - - - - - #40 - - - - - #41 - - - - - #42 - - - - - #43 - - - - - #44 - - - - - #45 - - - - - #46 - - - - - #47 - - - - - #48 - - - - - #49 - - - - - #50 - - - - - Abundance M - - - - - Abundance M+1 - - - - - Abundance M'-1 - - - - - Abundance M' - - - - - Xn - - - - - M enrichment - - - - - M' enrichment - - - - - - - - - - buttonBox - accepted() - Dialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - Dialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/src/mePyGuis/guis/combineResultsDialog.ui b/src/mePyGuis/guis/combineResultsDialog.ui deleted file mode 100644 index 59c5a6d..0000000 --- a/src/mePyGuis/guis/combineResultsDialog.ui +++ /dev/null @@ -1,505 +0,0 @@ - - - Dialog - - - - 0 - 0 - 602 - 750 - - - - MetExtract II - - - - - - <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Combine results</span></p><p>Combine the data processing results from several MetExtract II experiments. For example, if three experiments (U-13C-labeling, U-15N-labeling and a tracer-biotransformation experiment) have been carried out, the results can be combined into a singe data matrix using this tool. The tool is a very basic tool and does not perform any sort of chromatographic alignment or a correction for m/z shifts across the chromatograms. </p></body></html> - - - true - - - - - - - - 0 - 0 - - - - Input experiments - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - 20 - 10 - - - - - - - - Load - - - - - - - Load - - - - - - - Load - - - - - - - Results experiment A - - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 10 - - - - - - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 10 - - - - - - - - - - - Qt::Vertical - - - - 20 - 10 - - - - - - - - Results experiment C - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - 20 - 10 - - - - - - - - Results experiment B - - - - - - - - - - Prefix experiment B - - - - - - - Prefix experiment A - - - - - - - Load - - - - - - - Prefix experiment C - - - - - - - Results experiment D - - - - - - - Prefix experiment D - - - - - - - Results experiment E - - - - - - - Prefix experiment E - - - - - - - Qt::Horizontal - - - - - - - Load - - - - - - - Results experiment F - - - - - - - - - - Prefix experiment F - - - - - - - Load - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - - - - - <html><head/><body><p><span style=" font-size:32pt;">A</span></p></body></html> - - - - - - - <html><head/><body><p><span style=" font-size:32pt;">B</span></p></body></html> - - - expBSave - - - - - - - <html><head/><body><p><span style=" font-size:32pt; color:#8b8b8b;">C</span></p></body></html> - - - - - - - <html><head/><body><p><span style=" font-size:32pt; color:#8b8b8b;">D</span></p></body></html> - - - - - - - <html><head/><body><p><span style=" font-size:32pt; color:#8b8b8b;">E</span></p></body></html> - - - - - - - <html><head/><body><p><span style=" font-size:32pt; color:#8b8b8b;">F</span></p></body></html> - - - - - - - - - - QLayout::SetMinimumSize - - - - - Save combined results to - - - - - - - - - - Select - - - - - - - - - Processing parameters - - - - - - Maximum m/z deviation (± ppm) - - - - - - - Maximum retentiontime deviation (± minutes) - - - - - - - 0.000000000000000 - - - 5.000000000000000 - - - - - - - 0.150000000000000 - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 0 - 0 - - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Run - - - - - - - - - expASave - expALoad - expAPrefix - expBSave - expBLoad - expBPrefix - expCSave - expCLoad - expCPrefix - expDSave - expDLoad - expDPrefix - expESave - expELoad - expEPrefix - expFSave - expFLoad - expFPrefix - maxPPMDev - maxRTDev - saveResults - loadResults - run - - - - diff --git a/src/mePyGuis/guis/groupEditor.ui b/src/mePyGuis/guis/groupEditor.ui deleted file mode 100644 index 68eb689..0000000 --- a/src/mePyGuis/guis/groupEditor.ui +++ /dev/null @@ -1,468 +0,0 @@ - - - GroupEditor - - - - 0 - 0 - 781 - 552 - - - - Dialog - - - - - - - - - 9 - - - 9 - - - 9 - - - 9 - - - - - - - font: 10pt; - - - Options - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - false - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Ok - - - - - - - Cancel - - - - - - - - - Qt::Horizontal - - - - - - - - - font: 10pt; - - - Files - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Specify the measurement files for this group</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - - 200 - 40 - - - - - - - - - - QAbstractItemView::MultiSelection - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Add folder - - - - - - - Add file(s) - - - - - - - Remove selected - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - font: 10pt; - - - Actions - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - - - font: 10pt; - - - Group name - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - Qt::Vertical - - - - - - - Qt::Vertical - - - - - - - - - Use sample for MSMS targets - - - - - - - Remove (false positive) - - - - - - - Use samples for metabolite grouping - - - true - - - - - - - Omit features - - - true - - - - - - Minimum found - - - - - - - 1 - - - 999 - - - 1 - - - - - - - - - - - - - Color - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - dialogFinished - groupName - groupFiles - addFolder - addFiles - removeSelected - useForMetaboliteGrouping - removeAsFalsePositive - useAsMSMSTarget - dialogCanceled - - - - diff --git a/src/mePyGuis/guis/heteroAtomEditor.ui b/src/mePyGuis/guis/heteroAtomEditor.ui deleted file mode 100644 index 4d87592..0000000 --- a/src/mePyGuis/guis/heteroAtomEditor.ui +++ /dev/null @@ -1,209 +0,0 @@ - - - Dialog - - - - 0 - 0 - 765 - 474 - - - - Relationship configuration - - - - - - - - - - - - - font: 10pt; - - - Heteroatoms (elements) - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the elements you want to use for the targeted search for elements other then the labeling element. Each row represents one element</p><p>The first column specifies the chemical symbol of the respective isotope. Use &lt;protonNumber&gt;&lt;ElementSymbol&gt; (e.g. <sup>34</sup>S)<br/>The second column specifies the mass offset to the most abundant isotope of the elment (e.g. <sup>34</sup>S and <sup>32</sup>S have a mass difference of 1.9958)<br/>The third column specifies the expected intensity of this isotope in respect to the most abundant isotope of the respective element (e.g. <sup>34</sup>S occours in nature with a probability of 4.21% and <sup>32</sup>S with a probability of 95.02%. Therefore the calculated ratio for a molecule with a singe sulphur atom is 4.43%)<br/>The fourth and fifth column specify the minimal and maximal number this element can occour in non-targetd group annotation. </p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - - 200 - 0 - - - - - - - - - - Qt::Horizontal - - - - - - - - - Load defaults - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Discard - - - - - - - Accept - - - - - - - - - true - - - - - - - Qt::Horizontal - - - - - - - - - font: 10pt; - - - Actions - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - Qt::Vertical - - - - - - - - - - diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index c3fa038..c740144 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -1861,6 +1861,38 @@ def setupUi(self, MainWindow): self.horizontalLayout_17.addWidget(self.groupingRT) self.gridLayout_37.addLayout(self.horizontalLayout_17, 0, 1, 1, 1) self.verticalLayout_9.addWidget(self.groupResults) + self.integratedMissedPeaks = QtWidgets.QGroupBox(self.frame_bracketResults) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.integratedMissedPeaks.sizePolicy().hasHeightForWidth()) + self.integratedMissedPeaks.setSizePolicy(sizePolicy) + self.integratedMissedPeaks.setCheckable(True) + self.integratedMissedPeaks.setObjectName(_fromUtf8("integratedMissedPeaks")) + self.gridLayout_38 = QtWidgets.QGridLayout(self.integratedMissedPeaks) + self.gridLayout_38.setObjectName(_fromUtf8("gridLayout_38")) + self.horizontalLayout_15 = QtWidgets.QHBoxLayout() + self.horizontalLayout_15.setObjectName(_fromUtf8("horizontalLayout_15")) + self.label_55 = QtWidgets.QLabel(self.integratedMissedPeaks) + self.label_55.setObjectName(_fromUtf8("label_55")) + self.horizontalLayout_15.addWidget(self.label_55) + self.integrationMaxTimeDifference = QtWidgets.QDoubleSpinBox(self.integratedMissedPeaks) + self.integrationMaxTimeDifference.setDecimals(2) + self.integrationMaxTimeDifference.setProperty("value", 0.1) + self.integrationMaxTimeDifference.setObjectName(_fromUtf8("integrationMaxTimeDifference")) + self.horizontalLayout_15.addWidget(self.integrationMaxTimeDifference) + self.label_68 = QtWidgets.QLabel(self.integratedMissedPeaks) + self.label_68.setObjectName(_fromUtf8("label_68")) + self.horizontalLayout_15.addWidget(self.label_68) + self.reintegrateIntensityCutoff = QtWidgets.QSpinBox(self.integratedMissedPeaks) + self.reintegrateIntensityCutoff.setProperty("showGroupSeparator", True) + self.reintegrateIntensityCutoff.setSuffix(_fromUtf8("")) + self.reintegrateIntensityCutoff.setMaximum(100000000) + self.reintegrateIntensityCutoff.setSingleStep(1000) + self.reintegrateIntensityCutoff.setObjectName(_fromUtf8("reintegrateIntensityCutoff")) + self.horizontalLayout_15.addWidget(self.reintegrateIntensityCutoff) + self.gridLayout_38.addLayout(self.horizontalLayout_15, 0, 0, 1, 1) + self.verticalLayout_9.addWidget(self.integratedMissedPeaks) self.convoluteResults = QtWidgets.QGroupBox(self.frame_bracketResults) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -1898,55 +1930,33 @@ def setupUi(self, MainWindow): self.metaboliteClusterMinConnections.setProperty("value", 1) self.metaboliteClusterMinConnections.setObjectName(_fromUtf8("metaboliteClusterMinConnections")) self.horizontalLayout_8.addWidget(self.metaboliteClusterMinConnections) + self.gridLayout_14.addLayout(self.horizontalLayout_8, 0, 0, 1, 1) + self.horizontalLayout_sil = QtWidgets.QHBoxLayout() + self.horizontalLayout_sil.setObjectName(_fromUtf8("horizontalLayout_sil")) self.useSILRatioForConvolution = QtWidgets.QCheckBox(self.convoluteResults) self.useSILRatioForConvolution.setObjectName(_fromUtf8("useSILRatioForConvolution")) - self.horizontalLayout_8.addWidget(self.useSILRatioForConvolution) + self.horizontalLayout_sil.addWidget(self.useSILRatioForConvolution) + spacerItemSil = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_sil.addItem(spacerItemSil) + self.gridLayout_14.addLayout(self.horizontalLayout_sil, 1, 0, 1, 1) + self.horizontalLayout_abundance = QtWidgets.QHBoxLayout() + self.horizontalLayout_abundance.setObjectName(_fromUtf8("horizontalLayout_abundance")) self.useAbundanceSimilarityForConvolution = QtWidgets.QCheckBox(self.convoluteResults) self.useAbundanceSimilarityForConvolution.setChecked(True) self.useAbundanceSimilarityForConvolution.setObjectName(_fromUtf8("useAbundanceSimilarityForConvolution")) - self.horizontalLayout_8.addWidget(self.useAbundanceSimilarityForConvolution) + self.horizontalLayout_abundance.addWidget(self.useAbundanceSimilarityForConvolution) self.label_122 = QtWidgets.QLabel(self.convoluteResults) self.label_122.setObjectName(_fromUtf8("label_122")) - self.horizontalLayout_8.addWidget(self.label_122) + self.horizontalLayout_abundance.addWidget(self.label_122) self.abundanceSimilarityThreshold = QtWidgets.QDoubleSpinBox(self.convoluteResults) self.abundanceSimilarityThreshold.setMaximum(100.0) self.abundanceSimilarityThreshold.setProperty("value", 85.0) self.abundanceSimilarityThreshold.setObjectName(_fromUtf8("abundanceSimilarityThreshold")) - self.horizontalLayout_8.addWidget(self.abundanceSimilarityThreshold) - self.gridLayout_14.addLayout(self.horizontalLayout_8, 0, 0, 1, 1) + self.horizontalLayout_abundance.addWidget(self.abundanceSimilarityThreshold) + spacerItemAbundance = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout_abundance.addItem(spacerItemAbundance) + self.gridLayout_14.addLayout(self.horizontalLayout_abundance, 2, 0, 1, 1) self.verticalLayout_9.addWidget(self.convoluteResults) - self.integratedMissedPeaks = QtWidgets.QGroupBox(self.frame_bracketResults) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.integratedMissedPeaks.sizePolicy().hasHeightForWidth()) - self.integratedMissedPeaks.setSizePolicy(sizePolicy) - self.integratedMissedPeaks.setCheckable(True) - self.integratedMissedPeaks.setObjectName(_fromUtf8("integratedMissedPeaks")) - self.gridLayout_38 = QtWidgets.QGridLayout(self.integratedMissedPeaks) - self.gridLayout_38.setObjectName(_fromUtf8("gridLayout_38")) - self.horizontalLayout_15 = QtWidgets.QHBoxLayout() - self.horizontalLayout_15.setObjectName(_fromUtf8("horizontalLayout_15")) - self.label_55 = QtWidgets.QLabel(self.integratedMissedPeaks) - self.label_55.setObjectName(_fromUtf8("label_55")) - self.horizontalLayout_15.addWidget(self.label_55) - self.integrationMaxTimeDifference = QtWidgets.QDoubleSpinBox(self.integratedMissedPeaks) - self.integrationMaxTimeDifference.setDecimals(2) - self.integrationMaxTimeDifference.setProperty("value", 0.1) - self.integrationMaxTimeDifference.setObjectName(_fromUtf8("integrationMaxTimeDifference")) - self.horizontalLayout_15.addWidget(self.integrationMaxTimeDifference) - self.label_68 = QtWidgets.QLabel(self.integratedMissedPeaks) - self.label_68.setObjectName(_fromUtf8("label_68")) - self.horizontalLayout_15.addWidget(self.label_68) - self.reintegrateIntensityCutoff = QtWidgets.QSpinBox(self.integratedMissedPeaks) - self.reintegrateIntensityCutoff.setProperty("showGroupSeparator", True) - self.reintegrateIntensityCutoff.setSuffix(_fromUtf8("")) - self.reintegrateIntensityCutoff.setMaximum(100000000) - self.reintegrateIntensityCutoff.setSingleStep(1000) - self.reintegrateIntensityCutoff.setObjectName(_fromUtf8("reintegrateIntensityCutoff")) - self.horizontalLayout_15.addWidget(self.reintegrateIntensityCutoff) - self.gridLayout_38.addLayout(self.horizontalLayout_15, 0, 0, 1, 1) - self.verticalLayout_9.addWidget(self.integratedMissedPeaks) self.checkBox_expPeakArea = QtWidgets.QCheckBox(self.frame_bracketResults) self.checkBox_expPeakArea.setChecked(True) self.checkBox_expPeakArea.setObjectName(_fromUtf8("checkBox_expPeakArea")) diff --git a/src/mePyGuis/statisticsTab.py b/src/mePyGuis/statisticsTab.py index ad73169..4239998 100644 --- a/src/mePyGuis/statisticsTab.py +++ b/src/mePyGuis/statisticsTab.py @@ -28,6 +28,9 @@ QPushButton, QScrollArea, QSplitter, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, QTableWidgetItem, QTreeWidget, QTreeWidgetItem, @@ -446,6 +449,20 @@ def __lt__(self, other): return super().__lt__(other) +class _BoldSelectedDelegate(QStyledItemDelegate): + """Renders selected rows bold without changing their background color.""" + + def paint(self, painter, option, index) -> None: + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + if opt.state & QStyle.State_Selected: + opt.state &= ~QStyle.State_Selected + font = opt.font + font.setBold(True) + opt.font = font + super().paint(painter, opt, index) + + class SelectedFeaturesTable(QTreeWidget): """Tree-like view for selected features, grouped by Group ID.""" @@ -458,7 +475,8 @@ def __init__(self, parent=None): self.header().setSectionResizeMode(QHeaderView.ResizeToContents) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.setStyleSheet("QTreeWidget::item:selected { background-color: rgba(80, 200, 80, 120); color: black; }") + self.setStyleSheet("") + self.setItemDelegate(_BoldSelectedDelegate(self)) self.setSortingEnabled(False) self.feature_data = [] self._feature_item_by_id = {} From 2fea1cb6e59c9874a1507cdd5fbd7664ece7fad4 Mon Sep 17 00:00:00 2001 From: chrboku Date: Mon, 11 May 2026 16:22:21 +0200 Subject: [PATCH 16/27] Implement sample peaks tab with navigation and dynamic plotting --- src/MExtract.py | 360 ++++++++++++++++++++++++++++++++++++- src/mePyGuis/mainWindow.py | 48 +++++ 2 files changed, 406 insertions(+), 2 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 2677663..2188e30 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -1912,6 +1912,7 @@ def resultsExperimentChanged(self): self.drawCanvas(self.ui.resultsExperimentSeparatedPeaks_plot) self.drawCanvas(self.ui.resultsExperimentMSScanPeaks_plot) self.drawCanvas(self.ui.resultsExperimentAbundance_plot, showLegendOverwrite=False) + self.updateSamplePeaksTab([]) return if len(self.ui.resultsExperiment_TreeWidget.selectedItems()) == 0: @@ -1919,6 +1920,7 @@ def resultsExperimentChanged(self): self.drawCanvas(self.ui.resultsExperimentSeparatedPeaks_plot) self.drawCanvas(self.ui.resultsExperimentMSScanPeaks_plot) self.drawCanvas(self.ui.resultsExperimentAbundance_plot, showLegendOverwrite=False) + self.updateSamplePeaksTab([]) return plotItems = self._getSelectedExperimentPlotItems() @@ -2236,6 +2238,9 @@ def resultsExperimentChanged(self): # Update peak details tab self.updatePeakDetailsTab(plotItems) + # Update sample peaks tab + self.updateSamplePeaksTab(plotItems) + def _getSelectedExperimentPlotItems(self): plotItems = [] for item in self.ui.resultsExperiment_TreeWidget.selectedItems(): @@ -2414,6 +2419,245 @@ def _refreshExperimentEICs(self, *args): def _refreshExperimentAbundancePlot(self, *args): self.updateExperimentAbundancePlot(self._getSelectedExperimentPlotItems()) + def updateSamplePeaksTab(self, plotItems): + """Prepare data for per-sample peak plots and render the first page.""" + # Cache data for pagination + self._samplePeaks_plotItems = list(plotItems) if plotItems else [] + self._samplePeaks_page = 0 + + # Pre-build sorted sample list once + self._samplePeaks_entries = [] + if plotItems and hasattr(self, "loadedMZXMLs") and self.loadedMZXMLs is not None: + all_groups = self.getAllSampleGroups() + group_order = {str(grp.name): i for i, grp in enumerate(all_groups)} + seen = set() + raw = [] + for grp in all_groups: + group_name = str(grp.name) + for fi in grp.files: + fi_str = str(fi).replace("\\", "/") + a = fi_str[fi_str.rfind("/") + 1 :] + for ext in (".mzXML", ".mzxml", ".mzML", ".mzml"): + if a.lower().endswith(ext.lower()): + a = a[: -len(ext)] + break + key = (group_name, a) + if key not in seen: + seen.add(key) + raw.append((group_name, a, fi_str)) + self._samplePeaks_entries = sorted(raw, key=lambda x: (group_order.get(x[0], 0), x[1].lower())) + + self._renderSamplePeaksPage() + + def _renderSamplePeaksPage(self): + """Render the current page (4×4 grid) of per-sample peak subplots.""" + _PAGE_ROWS = 4 + _PAGE_COLS = 4 + _PAGE_SIZE = _PAGE_ROWS * _PAGE_COLS + + fig = self.ui.resultsExperimentSamplePeaks_plot.fig + fig.clear() + canvas = self.ui.resultsExperimentSamplePeaks_plot.canvas + + sample_entries = getattr(self, "_samplePeaks_entries", []) + plotItems = getattr(self, "_samplePeaks_plotItems", []) + page = getattr(self, "_samplePeaks_page", 0) + + n_total = len(sample_entries) + n_pages = max(1, (n_total + _PAGE_SIZE - 1) // _PAGE_SIZE) + page = max(0, min(page, n_pages - 1)) + self._samplePeaks_page = page + + # Update navigation buttons / label + prev_btn = self.ui.pushButton_samplePeaksPrev + next_btn = self.ui.pushButton_samplePeaksNext + page_lbl = self.ui.label_samplePeaksPage + prev_btn.setEnabled(page > 0) + next_btn.setEnabled(page < n_pages - 1) + if n_total > 0: + page_lbl.setText(f"Page {page + 1} / {n_pages} ({n_total} samples)") + else: + page_lbl.setText("") + + if not plotItems or n_total == 0: + canvas.draw() + return + + if not hasattr(self, "experimentResults") or self.experimentResults is None: + canvas.draw() + return + + selected_table = getattr(self.experimentResults, "selected_table", None) + if selected_table is None or selected_table not in self.experimentResults.db_con.tables: + canvas.draw() + return + + table_df = self.experimentResults.db_con.tables[selected_table] + feature_ids = [pi.id for pi in plotItems if getattr(pi, "id", None) is not None] + rows_by_num = {} + if feature_ids: + filtered = table_df.filter(pl.col("Num").is_in(feature_ids)) + rows_by_num = {row["Num"]: row for row in filtered.to_dicts()} + + ppm = self.ui.doubleSpinBox_resultsExperiment_EICppm.value() + + # Slice the current page + page_start = page * _PAGE_SIZE + page_entries = sample_entries[page_start : page_start + _PAGE_SIZE] + n_on_page = len(page_entries) + + n_cols = min(_PAGE_COLS, n_on_page) + n_rows = (n_on_page + n_cols - 1) // n_cols + + legend_fraction = 0.18 + fig_width = n_cols * 4.5 + 2.5 + fig_height = n_rows * 3.8 + fig.set_size_inches(fig_width, fig_height) + + axes = fig.subplots(n_rows, n_cols, squeeze=False) + + # Build colour map per feature + cmap = matplotlib.cm.get_cmap("tab10") + feature_colors = {pi.id: cmap(i % 10) for i, pi in enumerate(plotItems)} + + # Legend handles + import matplotlib.lines as mlines + + legend_handles = [mlines.Line2D([], [], color=feature_colors[pi.id], linewidth=1.5, label=f"Feature {pi.id} ({pi.mz:.4f})") for pi in plotItems] + + for s_idx, (group_name, sample_name, fi_str) in enumerate(page_entries): + row = s_idx // n_cols + col = s_idx % n_cols + ax = axes[row][col] + ax.set_title(f"{group_name}\n{sample_name}", fontsize=8, pad=3) + ax.tick_params(labelsize=7) + ax.set_xlabel("RT (min)", fontsize=7) + ax.set_ylabel("Scaled intensity", fontsize=7) + + if not hasattr(self, "loadedMZXMLs") or self.loadedMZXMLs is None or fi_str not in self.loadedMZXMLs: + ax.text(0.5, 0.5, "Not loaded", ha="center", va="center", transform=ax.transAxes, fontsize=7, color="gray") + continue + + mzxml_obj = self.loadedMZXMLs[fi_str] + + for pi in plotItems: + color = feature_colors[pi.id] + row_data = rows_by_num.get(pi.id, {}) + + start_rt_min = None + apex_rt_min = None + end_rt_min = None + + if row_data: + sv = row_data.get(f"{sample_name}_N_startRT") + av = row_data.get(f"{sample_name}_N_apexRT") + ev = row_data.get(f"{sample_name}_N_endRT") + if sv is not None and av is not None and ev is not None: + try: + # DB columns store RT already in minutes (seconds / 60 at write time) + # Use first value when multiple peaks are stored as semicolon list + start_rt_min = float(str(sv).split(";")[0]) + apex_rt_min = float(str(av).split(";")[0]) + end_rt_min = float(str(ev).split(";")[0]) + except (TypeError, ValueError): + start_rt_min = None + + if start_rt_min is None or end_rt_min is None: + global_rt_min = pi.rt / 60.0 + start_rt_min = global_rt_min - 0.3 + apex_rt_min = global_rt_min + end_rt_min = global_rt_min + 0.3 + + peak_width = max(end_rt_min - start_rt_min, 1e-6) + context = 0.75 * peak_width + window_start = start_rt_min - context + window_end = end_rt_min + context + + # Determine scan event + scanEvent = pi.scanEvent if hasattr(pi, "scanEvent") and pi.scanEvent else None + if scanEvent is None: + filter_lines = mzxml_obj.getFilterLines(includeMS1=True, includeMS2=False, includePosPolarity=True, includeNegPolarity=True) + if filter_lines: + ion_mode = getattr(pi, "ionisationMode", None) + if ion_mode and "+" in str(ion_mode): + scanEvent = next((fl for fl in filter_lines if "+" in fl), filter_lines[0]) + elif ion_mode and "-" in str(ion_mode): + scanEvent = next((fl for fl in filter_lines if "-" in fl), filter_lines[0]) + else: + scanEvent = filter_lines[0] + + if scanEvent is None: + continue + + available = mzxml_obj.getFilterLines(includeMS1=True, includeMS2=False, includePosPolarity=True, includeNegPolarity=True) + if scanEvent not in available: + continue + + try: + eic, times, _scan_ids, _mzs = mzxml_obj.getEIC(pi.mz, ppm=ppm, filterLine=scanEvent) + except Exception: + continue + + if len(times) == 0: + continue + + times_min = [t / 60.0 for t in times] + eic_list = list(eic) + + # Crop to view window + window_indices = [j for j, t in enumerate(times_min) if window_start <= t <= window_end] + if not window_indices: + continue + + cropped_times = [times_min[j] for j in window_indices] + cropped_eic = [eic_list[j] for j in window_indices] + + # Apex scaling: intensity at the scan closest to the feature apex RT + apex_idx = min(range(len(times_min)), key=lambda j: abs(times_min[j] - apex_rt_min)) + apex_int = eic_list[apex_idx] + scale = (1.0 / apex_int) if apex_int > 0 else 1.0 + + scaled_eic = [v * scale for v in cropped_eic] + ax.plot(cropped_times, scaled_eic, color=color, linewidth=1.0, alpha=0.85) + + # Shade the peak region + boundary = [(t, v) for t, v in zip(cropped_times, scaled_eic) if start_rt_min <= t <= end_rt_min] + if boundary: + ax.fill_between([x[0] for x in boundary], [x[1] for x in boundary], alpha=0.15, color=color) + + # Hide unused subplots in last row + for s_idx in range(n_on_page, n_rows * n_cols): + axes[s_idx // n_cols][s_idx % n_cols].set_visible(False) + + if legend_handles: + fig.legend( + handles=legend_handles, + loc="center right", + bbox_to_anchor=(1.0, 0.5), + fontsize=8, + frameon=True, + title="Features", + title_fontsize=8, + ) + + fig.tight_layout(rect=[0, 0, 1.0 - legend_fraction, 1.0]) + + dpi = self.ui.resultsExperimentSamplePeaks_plot.dpi + w_px = int(fig_width * dpi) + h_px = int(fig_height * dpi) + canvas.setMinimumSize(w_px, h_px) + self.ui.resultsExperimentSamplePeaks_widget.setMinimumSize(w_px, h_px + 40) + + canvas.draw() + + def _samplePeaksPrevPage(self): + self._samplePeaks_page = max(0, getattr(self, "_samplePeaks_page", 0) - 1) + self._renderSamplePeaksPage() + + def _samplePeaksNextPage(self): + self._samplePeaks_page = getattr(self, "_samplePeaks_page", 0) + 1 + self._renderSamplePeaksPage() + # # @@ -10021,11 +10265,55 @@ def resultsExperimentChangedNew(self, askForFeature=False): ): availableFilterLines.add(fl) + # --- Load saved custom features from JSON --- + import json as _json + + _custom_features_path = os.path.join(local_folder, "custom_features.json") + + def _load_saved_features(): + try: + if os.path.exists(_custom_features_path): + with open(_custom_features_path, "r", encoding="utf-8") as _f: + return _json.load(_f) + except Exception: + pass + return [] + + def _save_features(features): + try: + with open(_custom_features_path, "w", encoding="utf-8") as _f: + _json.dump(features, _f, indent=2) + except Exception as ex: + logging.warning(f"Could not save custom features: {ex}") + + saved_features = _load_saved_features() + dlg = QtWidgets.QDialog(self) dlg.setWindowTitle("Custom feature") - dlg.setMinimumWidth(400) + dlg.setMinimumWidth(450) form = QtWidgets.QFormLayout(dlg) + # --- Saved compounds section --- + saved_layout = QtWidgets.QHBoxLayout() + saved_combo = QtWidgets.QComboBox() + saved_combo.setMinimumWidth(200) + saved_combo.addItem("-- select saved compound --") + for sf in saved_features: + saved_combo.addItem(sf.get("name", "")) + saved_layout.addWidget(saved_combo) + del_saved_btn = QtWidgets.QPushButton("Delete") + saved_layout.addWidget(del_saved_btn) + form.addRow("Saved compounds:", saved_layout) + + sep0 = QtWidgets.QFrame() + sep0.setFrameShape(QtWidgets.QFrame.HLine) + form.addRow(sep0) + + # --- Name field --- + name_edit = QtWidgets.QLineEdit() + name_edit.setPlaceholderText("Optional – set to save this feature") + form.addRow("Name:", name_edit) + # --- Sum formula helper --- formula_layout = QtWidgets.QHBoxLayout() native_formula_edit = QtWidgets.QLineEdit() @@ -10080,7 +10368,6 @@ def _calc_mz_from_formula(): except Exception as ex: QtWidgets.QMessageBox.warning(dlg, "Formula error", str(ex)) - # calcuate on button click and on change of formulas/adducts native_formula_edit.textEdited.connect(_calc_mz_from_formula) labeled_formula_edit.textEdited.connect(_calc_mz_from_formula) adduct_combo.currentIndexChanged.connect(_calc_mz_from_formula) @@ -10096,6 +10383,37 @@ def _calc_mz_from_formula(): fl_combo.addItems(sorted(availableFilterLines)) form.addRow("Filter line:", fl_combo) + def _populate_from_saved(index): + """Fill form fields when a saved compound is selected.""" + if index <= 0 or index - 1 >= len(saved_features): + return + sf = saved_features[index - 1] + name_edit.setText(sf.get("name", "")) + native_formula_edit.setText(sf.get("native_formula", "")) + labeled_formula_edit.setText(sf.get("labeled_formula", "")) + mz_spin.setValue(sf.get("mz", 0.0)) + lmz_spin.setValue(sf.get("lmz", 0.0)) + rt_spin.setValue(sf.get("rt", 0.0)) + fl_val = sf.get("filter_line", "") + idx_fl = fl_combo.findText(fl_val) + if idx_fl >= 0: + fl_combo.setCurrentIndex(idx_fl) + adduct_name = sf.get("adduct", "") + idx_ad = adduct_combo.findText(adduct_name) + if idx_ad >= 0: + adduct_combo.setCurrentIndex(idx_ad) + + def _delete_saved(): + idx = saved_combo.currentIndex() + if idx <= 0 or idx - 1 >= len(saved_features): + return + saved_features.pop(idx - 1) + _save_features(saved_features) + saved_combo.removeItem(idx) + + saved_combo.currentIndexChanged.connect(_populate_from_saved) + del_saved_btn.clicked.connect(_delete_saved) + btn_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) btn_box.accepted.connect(dlg.accept) btn_box.rejected.connect(dlg.reject) @@ -10109,6 +10427,27 @@ def _calc_mz_from_formula(): rt = rt_spin.value() fl = fl_combo.currentText() + # Save if a name was provided + compound_name = name_edit.text().strip() + if compound_name: + # Update existing entry or append new one + existing = next((sf for sf in saved_features if sf.get("name") == compound_name), None) + entry = { + "name": compound_name, + "native_formula": native_formula_edit.text().strip(), + "labeled_formula": labeled_formula_edit.text().strip(), + "adduct": adduct_combo.currentText(), + "mz": mz, + "lmz": lmz, + "rt": rt, + "filter_line": fl, + } + if existing is not None: + existing.update(entry) + else: + saved_features.append(entry) + _save_features(saved_features) + plotItems.append(Bunch(mz=mz, lmz=lmz, rt=rt * 60.0, scanEvent=fl)) else: for item in self.ui.resultsExperiment_TreeWidget.selectedItems(): @@ -11384,6 +11723,8 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal self.ui.comboBox_abundancePlotType.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) self.ui.comboBox_abundanceScale.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) self.ui.comboBox_abundanceScalingMode.currentIndexChanged.connect(self._refreshExperimentAbundancePlot) + self.ui.pushButton_samplePeaksPrev.clicked.connect(self._samplePeaksPrevPage) + self.ui.pushButton_samplePeaksNext.clicked.connect(self._samplePeaksNextPage) self.ui.eicSmoothingWindow.currentIndexChanged.connect(self.smoothingWindowChanged) self.smoothingWindowChanged() @@ -11613,6 +11954,21 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal vbox.addWidget(self.ui.resultsExperimentAbundance_plot.canvas) self.ui.resultsExperimentAbundance_widget.setLayout(vbox) + # Setup sample peaks plot (dynamic per-sample subplot grid) + self.ui.resultsExperimentSamplePeaks_plot = QtCore.QObject() + self.ui.resultsExperimentSamplePeaks_plot.dpi = 72 + self.ui.resultsExperimentSamplePeaks_plot.fig = Figure(facecolor="white") + self.ui.resultsExperimentSamplePeaks_plot.canvas = FigureCanvas(self.ui.resultsExperimentSamplePeaks_plot.fig) + self.ui.resultsExperimentSamplePeaks_plot.canvas.setParent(self.ui.resultsExperimentSamplePeaks_widget) + self.ui.resultsExperimentSamplePeaks_plot.mpl_toolbar = NavigationToolbar( + self.ui.resultsExperimentSamplePeaks_plot.canvas, + self.ui.resultsExperimentSamplePeaks_widget, + ) + vbox_sp = QtWidgets.QVBoxLayout() + vbox_sp.addWidget(self.ui.resultsExperimentSamplePeaks_plot.mpl_toolbar) + vbox_sp.addWidget(self.ui.resultsExperimentSamplePeaks_plot.canvas) + self.ui.resultsExperimentSamplePeaks_widget.setLayout(vbox_sp) + # Setup experiment MSMS plot - multiple MS/MS spectra subplots self.ui.plMSMS_exp = QtCore.QObject() self.ui.plMSMS_exp.dpi = 50 diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index c740144..3b1d527 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -60,6 +60,7 @@ def setupUi(self, MainWindow): self.INFOLabel.setStyleSheet(_fromUtf8("")) self.INFOLabel.setText(_fromUtf8("")) self.INFOLabel.setObjectName(_fromUtf8("INFOLabel")) + self.INFOLabel.setVisible(False) self.gridLayout_11.addWidget(self.INFOLabel, 0, 0, 1, 1) self.tabWidget = QtWidgets.QTabWidget(self.centralWidget) self.tabWidget.setEnabled(True) @@ -2158,6 +2159,8 @@ def setupUi(self, MainWindow): self.gridLayout_5.setObjectName(_fromUtf8("gridLayout_5")) self.tabWidget_2 = QtWidgets.QTabWidget(self.widget1) self.tabWidget_2.setObjectName(_fromUtf8("tabWidget_2")) + self.tabWidget_2.setTabPosition(QtWidgets.QTabWidget.West) + self.tabWidget_2.setTabShape(QtWidgets.QTabWidget.Rounded) self.tab = QtWidgets.QWidget() self.tab.setObjectName(_fromUtf8("tab")) self.gridLayout_128 = QtWidgets.QGridLayout(self.tab) @@ -2177,6 +2180,7 @@ def setupUi(self, MainWindow): self.horizontalLayout_10.setObjectName(_fromUtf8("horizontalLayout_10")) self.label_95 = QtWidgets.QLabel(self.widget2) self.label_95.setObjectName(_fromUtf8("label_95")) + self.label_95.setVisible(False) self.horizontalLayout_10.addWidget(self.label_95) spacerItem58 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_10.addItem(spacerItem58) @@ -2286,6 +2290,7 @@ def setupUi(self, MainWindow): self.horizontalLayout_7.setObjectName(_fromUtf8("horizontalLayout_7")) self.label_96 = QtWidgets.QLabel(self.widget3) self.label_96.setObjectName(_fromUtf8("label_96")) + self.label_96.setVisible(False) self.horizontalLayout_7.addWidget(self.label_96) spacerItem59 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_7.addItem(spacerItem59) @@ -2490,6 +2495,7 @@ def setupUi(self, MainWindow): self.horizontalLayout_12.setObjectName(_fromUtf8("horizontalLayout_12")) self.label_97 = QtWidgets.QLabel(self.scrollAreaWidgetContents) self.label_97.setObjectName(_fromUtf8("label_97")) + self.label_97.setVisible(False) self.horizontalLayout_12.addWidget(self.label_97) spacerItem64 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_12.addItem(spacerItem64) @@ -2547,6 +2553,8 @@ def setupUi(self, MainWindow): self.gridLayout_40.addWidget(self.resultsExperiment_TreeWidget, 2, 0, 4, 1) self.tabWidget_3 = QtWidgets.QTabWidget(self.scrollAreaWidgetContents) self.tabWidget_3.setObjectName(_fromUtf8("tabWidget_3")) + self.tabWidget_3.setTabPosition(QtWidgets.QTabWidget.West) + self.tabWidget_3.setTabShape(QtWidgets.QTabWidget.Rounded) self.tab_5 = QtWidgets.QWidget() self.tab_5.setObjectName(_fromUtf8("tab_5")) self.gridLayout_42 = QtWidgets.QGridLayout(self.tab_5) @@ -2680,6 +2688,7 @@ def setupUi(self, MainWindow): self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) self.comboBox_abundanceScalingMode.addItem(_fromUtf8("")) + self.comboBox_abundanceScalingMode.setCurrentIndex(2) self.horizontalLayout_abundance_controls.addWidget(self.comboBox_abundanceScalingMode) spacerItem74 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) self.horizontalLayout_abundance_controls.addItem(spacerItem74) @@ -2694,6 +2703,40 @@ def setupUi(self, MainWindow): self.resultsExperimentAbundance_widget.setObjectName(_fromUtf8("resultsExperimentAbundance_widget")) self.gridLayout_abundance_profiles.addWidget(self.resultsExperimentAbundance_widget, 1, 0, 1, 1) self.tabWidget_3.addTab(self.tab_abundance_profiles, _fromUtf8("")) + + # Sample peaks tab + self.tab_sample_peaks = QtWidgets.QWidget() + self.tab_sample_peaks.setObjectName(_fromUtf8("tab_sample_peaks")) + self.gridLayout_sample_peaks = QtWidgets.QGridLayout(self.tab_sample_peaks) + self.gridLayout_sample_peaks.setObjectName(_fromUtf8("gridLayout_sample_peaks")) + # Navigation bar + self.horizontalLayout_sample_peaks_nav = QtWidgets.QHBoxLayout() + self.horizontalLayout_sample_peaks_nav.setObjectName(_fromUtf8("horizontalLayout_sample_peaks_nav")) + self.pushButton_samplePeaksPrev = QtWidgets.QPushButton(self.tab_sample_peaks) + self.pushButton_samplePeaksPrev.setObjectName(_fromUtf8("pushButton_samplePeaksPrev")) + self.pushButton_samplePeaksPrev.setText(_fromUtf8("< Previous")) + self.pushButton_samplePeaksPrev.setEnabled(False) + self.horizontalLayout_sample_peaks_nav.addWidget(self.pushButton_samplePeaksPrev) + self.label_samplePeaksPage = QtWidgets.QLabel(self.tab_sample_peaks) + self.label_samplePeaksPage.setObjectName(_fromUtf8("label_samplePeaksPage")) + self.label_samplePeaksPage.setAlignment(QtCore.Qt.AlignCenter) + self.label_samplePeaksPage.setText(_fromUtf8("")) + self.horizontalLayout_sample_peaks_nav.addWidget(self.label_samplePeaksPage) + self.pushButton_samplePeaksNext = QtWidgets.QPushButton(self.tab_sample_peaks) + self.pushButton_samplePeaksNext.setObjectName(_fromUtf8("pushButton_samplePeaksNext")) + self.pushButton_samplePeaksNext.setText(_fromUtf8("Next >")) + self.pushButton_samplePeaksNext.setEnabled(False) + self.horizontalLayout_sample_peaks_nav.addWidget(self.pushButton_samplePeaksNext) + self.gridLayout_sample_peaks.addLayout(self.horizontalLayout_sample_peaks_nav, 0, 0, 1, 1) + self.scrollArea_sample_peaks = QtWidgets.QScrollArea(self.tab_sample_peaks) + self.scrollArea_sample_peaks.setWidgetResizable(True) + self.scrollArea_sample_peaks.setObjectName(_fromUtf8("scrollArea_sample_peaks")) + self.resultsExperimentSamplePeaks_widget = QtWidgets.QWidget() + self.resultsExperimentSamplePeaks_widget.setObjectName(_fromUtf8("resultsExperimentSamplePeaks_widget")) + self.scrollArea_sample_peaks.setWidget(self.resultsExperimentSamplePeaks_widget) + self.gridLayout_sample_peaks.addWidget(self.scrollArea_sample_peaks, 1, 0, 1, 1) + self.tabWidget_3.addTab(self.tab_sample_peaks, _fromUtf8("")) + self.gridLayout_40.addWidget(self.tabWidget_3, 3, 1, 1, 1) self.scrollArea.setWidget(self.scrollAreaWidgetContents) self.gridLayout_2.addWidget(self.scrollArea, 0, 0, 1, 1) @@ -2725,6 +2768,7 @@ def setupUi(self, MainWindow): self.version.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing) self.version.setObjectName(_fromUtf8("version")) self.gridLayout_11.addWidget(self.version, 2, 0, 1, 1) + self.version.setVisible(False) MainWindow.setCentralWidget(self.centralWidget) self.menuBar = QtWidgets.QMenuBar(MainWindow) self.menuBar.setGeometry(QtCore.QRect(0, 0, 1954, 21)) @@ -3395,6 +3439,10 @@ def retranslateUi(self, MainWindow): self.tabWidget_3.indexOf(self.tab_abundance_profiles), _translate("MainWindow", "Abundance profiles", None), ) + self.tabWidget_3.setTabText( + self.tabWidget_3.indexOf(self.tab_sample_peaks), + _translate("MainWindow", "Sample peaks", None), + ) self.tabWidget.setTabText( self.tabWidget.indexOf(self.bracketedResultsTab), _translate("MainWindow", "Experiment results", None), From 5cab61cdf559dee6810e0e59ae8ecadd21ff4416 Mon Sep 17 00:00:00 2001 From: chrboku Date: Tue, 12 May 2026 11:05:59 +0200 Subject: [PATCH 17/27] minor ui adaption - added splitter in "Experimental results" --- src/mePyGuis/mainWindow.py | 50 +++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 3b1d527..1aae871 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -2448,7 +2448,25 @@ def setupUi(self, MainWindow): self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 1353, 776)) self.scrollAreaWidgetContents.setObjectName(_fromUtf8("scrollAreaWidgetContents")) self.gridLayout_40 = QtWidgets.QGridLayout(self.scrollAreaWidgetContents) + self.gridLayout_40.setContentsMargins(0, 0, 0, 0) self.gridLayout_40.setObjectName(_fromUtf8("gridLayout_40")) + # Horizontal splitter between left (tree) and right (detail) panels + self.splitter_exp_leftRight = QtWidgets.QSplitter(self.scrollAreaWidgetContents) + self.splitter_exp_leftRight.setOrientation(QtCore.Qt.Horizontal) + self.splitter_exp_leftRight.setObjectName(_fromUtf8("splitter_exp_leftRight")) + self.gridLayout_40.addWidget(self.splitter_exp_leftRight, 0, 0, 1, 1) + # Left panel + self.exp_left_panel = QtWidgets.QWidget(self.splitter_exp_leftRight) + self.exp_left_panel.setObjectName(_fromUtf8("exp_left_panel")) + self.exp_left_layout = QtWidgets.QVBoxLayout(self.exp_left_panel) + self.exp_left_layout.setContentsMargins(0, 0, 0, 0) + self.exp_left_layout.setObjectName(_fromUtf8("exp_left_layout")) + # Right panel + self.exp_right_panel = QtWidgets.QWidget(self.splitter_exp_leftRight) + self.exp_right_panel.setObjectName(_fromUtf8("exp_right_panel")) + self.exp_right_layout = QtWidgets.QGridLayout(self.exp_right_panel) + self.exp_right_layout.setContentsMargins(0, 0, 0, 0) + self.exp_right_layout.setObjectName(_fromUtf8("exp_right_layout")) self.groupBox_options3 = QtWidgets.QGroupBox(self.scrollAreaWidgetContents) self.groupBox_options3.setObjectName(_fromUtf8("groupBox_options3")) self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.groupBox_options3) @@ -2490,7 +2508,7 @@ def setupUi(self, MainWindow): self.showLegend_experiment.setChecked(False) self.showLegend_experiment.setObjectName(_fromUtf8("showLegend_experiment")) self.verticalLayout_4.addWidget(self.showLegend_experiment) - self.gridLayout_40.addWidget(self.groupBox_options3, 2, 1, 1, 1) + self.exp_right_layout.addWidget(self.groupBox_options3, 1, 0, 1, 1) self.horizontalLayout_12 = QtWidgets.QHBoxLayout() self.horizontalLayout_12.setObjectName(_fromUtf8("horizontalLayout_12")) self.label_97 = QtWidgets.QLabel(self.scrollAreaWidgetContents) @@ -2513,13 +2531,13 @@ def setupUi(self, MainWindow): self.showCustomFeature_pushButton = QtWidgets.QPushButton(self.scrollAreaWidgetContents) self.showCustomFeature_pushButton.setObjectName(_fromUtf8("showCustomFeature_pushButton")) self.horizontalLayout_12.addWidget(self.showCustomFeature_pushButton) - self.gridLayout_40.addLayout(self.horizontalLayout_12, 1, 1, 1, 1) + self.exp_right_layout.addLayout(self.horizontalLayout_12, 0, 0, 1, 1) self.expFilterWidget = QtWidgets.QWidget(self.scrollAreaWidgetContents) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) self.expFilterWidget.setSizePolicy(sizePolicy) - self.expFilterWidget.setMinimumSize(QtCore.QSize(500, 0)) + self.expFilterWidget.setMinimumSize(QtCore.QSize(200, 0)) self.expFilterWidget.setObjectName(_fromUtf8("expFilterWidget")) self.horizontalLayout_expFilter = QtWidgets.QHBoxLayout(self.expFilterWidget) self.horizontalLayout_expFilter.setContentsMargins(0, 0, 0, 0) @@ -2538,19 +2556,19 @@ def setupUi(self, MainWindow): self.expDataFilter.setSizePolicy(sizePolicy) self.expDataFilter.setObjectName(_fromUtf8("expDataFilter")) self.horizontalLayout_expFilter.addWidget(self.expDataFilter) - self.gridLayout_40.addWidget(self.expFilterWidget, 1, 0, 1, 1) + self.exp_left_layout.addWidget(self.expFilterWidget) self.resultsExperiment_TreeWidget = QtWidgets.QTreeWidget(self.scrollAreaWidgetContents) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.resultsExperiment_TreeWidget.sizePolicy().hasHeightForWidth()) self.resultsExperiment_TreeWidget.setSizePolicy(sizePolicy) - self.resultsExperiment_TreeWidget.setMinimumSize(QtCore.QSize(500, 0)) + self.resultsExperiment_TreeWidget.setMinimumSize(QtCore.QSize(200, 0)) self.resultsExperiment_TreeWidget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.resultsExperiment_TreeWidget.setColumnCount(6) self.resultsExperiment_TreeWidget.setObjectName(_fromUtf8("resultsExperiment_TreeWidget")) self.resultsExperiment_TreeWidget.header().setDefaultSectionSize(40) - self.gridLayout_40.addWidget(self.resultsExperiment_TreeWidget, 2, 0, 4, 1) + self.exp_left_layout.addWidget(self.resultsExperiment_TreeWidget) self.tabWidget_3 = QtWidgets.QTabWidget(self.scrollAreaWidgetContents) self.tabWidget_3.setObjectName(_fromUtf8("tabWidget_3")) self.tabWidget_3.setTabPosition(QtWidgets.QTabWidget.West) @@ -2559,18 +2577,22 @@ def setupUi(self, MainWindow): self.tab_5.setObjectName(_fromUtf8("tab_5")) self.gridLayout_42 = QtWidgets.QGridLayout(self.tab_5) self.gridLayout_42.setObjectName(_fromUtf8("gridLayout_42")) - self.resultsExperiment_widget = QtWidgets.QWidget(self.tab_5) + self.gridLayout_42.setContentsMargins(0, 0, 0, 0) + # Vertical splitter between top XIC chart and bottom MS scan chart + self.splitter_exp_rawxics = QtWidgets.QSplitter(self.tab_5) + self.splitter_exp_rawxics.setOrientation(QtCore.Qt.Vertical) + self.splitter_exp_rawxics.setObjectName(_fromUtf8("splitter_exp_rawxics")) + self.gridLayout_42.addWidget(self.splitter_exp_rawxics, 1, 0, 1, 1) + self.resultsExperiment_widget = QtWidgets.QWidget(self.splitter_exp_rawxics) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.resultsExperiment_widget.sizePolicy().hasHeightForWidth()) self.resultsExperiment_widget.setSizePolicy(sizePolicy) - self.resultsExperiment_widget.setMinimumSize(QtCore.QSize(700, 500)) + self.resultsExperiment_widget.setMinimumSize(QtCore.QSize(700, 200)) self.resultsExperiment_widget.setObjectName(_fromUtf8("resultsExperiment_widget")) - self.gridLayout_42.addWidget(self.resultsExperiment_widget, 1, 0, 1, 1) - self.resultsExperimentMSScan_widget = QtWidgets.QWidget(self.tab_5) + self.resultsExperimentMSScan_widget = QtWidgets.QWidget(self.splitter_exp_rawxics) self.resultsExperimentMSScan_widget.setObjectName(_fromUtf8("resultsExperimentMSScan_widget")) - self.gridLayout_42.addWidget(self.resultsExperimentMSScan_widget, 2, 0, 1, 1) self.tabWidget_3.addTab(self.tab_5, _fromUtf8("")) self.tab_6 = QtWidgets.QWidget() self.tab_6.setObjectName(_fromUtf8("tab_6")) @@ -2737,7 +2759,7 @@ def setupUi(self, MainWindow): self.gridLayout_sample_peaks.addWidget(self.scrollArea_sample_peaks, 1, 0, 1, 1) self.tabWidget_3.addTab(self.tab_sample_peaks, _fromUtf8("")) - self.gridLayout_40.addWidget(self.tabWidget_3, 3, 1, 1, 1) + self.exp_right_layout.addWidget(self.tabWidget_3, 2, 0, 1, 1) self.scrollArea.setWidget(self.scrollAreaWidgetContents) self.gridLayout_2.addWidget(self.scrollArea, 0, 0, 1, 1) self.tabWidget.addTab(self.bracketedResultsTab, _fromUtf8("")) From 330c387f8dfc2d80165a25228ba2a29888606ab7 Mon Sep 17 00:00:00 2001 From: chrboku Date: Wed, 13 May 2026 15:46:24 +0200 Subject: [PATCH 18/27] minor adaptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MSMS spectrum viewer — fragment m/z labels are no longer rotated; they now appear horizontally above each peak tip at twice the previous font size. RT filtering now uses the actual detected peak start/end time per sample instead of a fixed window around the apex. - File stats dialog — added intensity distribution statistics (min, 10–99th percentiles, max) computed from the actual measured signal intensities of all MS1 spectra, split by polarity (positive/negative). - Caching — file stats results are cached per file using an MD5/mtime key; the cache is automatically invalidated when the computation logic changes (versioned key). - Sortable, color-coded stats table — all columns sort numerically; cells are highlighted in proportion to their deviation from the column mean; the first column shows the experimental group with its assigned color. - minor bugfixes --- src/MExtract.py | 706 ++++++++++++++++++++++++++++------ src/bracketResults.py | 267 ++++++++++++- src/findIsoPairs.py | 30 +- src/mePyGuis/mainWindow.py | 32 +- src/mePyGuis/statisticsTab.py | 12 +- src/style.css | 1 + 6 files changed, 913 insertions(+), 135 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 2188e30..abac593 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -80,7 +80,7 @@ from .annotateResultMatrix import addGroup as grpAdd from .annotateResultMatrix import addStatsColumnToResults from .annotateResultMatrix import performGroupOmit as grpOmit -from .bracketResults import bracketResults, calculateMetaboliteGroups +from .bracketResults import bracketResults, calculateMetaboliteGroups, compute_sample_stats from .mePyGuis.mainWindow import Ui_MainWindow from .mePyGuis.QScrollableMessageBox import QScrollableMessageBox from .mePyGuis.TracerEdit import ConfiguredTracer, tracerEdit @@ -110,6 +110,27 @@ ABUNDANCE_BOXPLOT_SLOT_FILL_RATIO = 0.8 +class _NumericDateSortItem(QTableWidgetItem): + """QTableWidgetItem that sorts numerically or by ISO-timestamp when possible.""" + + def __lt__(self, other): + t_self = self.text() + t_other = other.text() + # Numeric comparison + try: + return float(t_self) < float(t_other) + except (ValueError, TypeError): + pass + # ISO timestamp comparison (e.g. "2026-04-02T14:51:17.405324") + try: + from datetime import datetime as _dt + + return _dt.fromisoformat(t_self) < _dt.fromisoformat(t_other) + except (ValueError, TypeError): + pass + return t_self < t_other + + # Helper function to safely load pickled data with error handling for old cached data def safe_pickle_loads(data, default_value=None, operation_name="loading data"): """ @@ -1108,6 +1129,212 @@ def addGroup( def showAddGroupDialogClicked(self): self.showAddGroupDialog() + def showFileStatsPopup(self): + """Collect all sample files from the groups table, compute stats, and show them in a popup dialog.""" + + # Build ordered list of (filepath, group_name, group_color) from all groups + all_files_with_groups = [] + for grp in self.getAllSampleGroups(): + for f in natSort(grp.files): + all_files_with_groups.append((str(f), str(grp.name), str(grp.color))) + + if not all_files_with_groups: + QtWidgets.QMessageBox.information(self, "File Stats", "No sample files configured in the groups table.") + return + + pos_se = str(self.ui.positiveScanEvent.currentText()) if self.ui.positiveScanEvent.count() > 0 else None + neg_se = str(self.ui.negativeScanEvent.currentText()) if self.ui.negativeScanEvent.count() > 0 else None + + pw = QtWidgets.QProgressDialog("Computing file stats...", "Cancel", 0, len(all_files_with_groups), self) + pw.setWindowTitle("File Stats") + pw.setWindowModality(QtCore.Qt.WindowModal) + # Show "current / total (percent%)" inside the bar + bar = pw.findChild(QtWidgets.QProgressBar) + if bar is not None: + bar.setFormat("%v / %m (%p%)") + pw.setValue(0) + pw.show() + + stats_rows = [] + for i, (f, group_name, group_color) in enumerate(all_files_with_groups): + pw.setLabelText(f"Processing: {os.path.basename(f)}") + pw.setValue(i) + QtWidgets.QApplication.processEvents() + if pw.wasCanceled(): + return + rows = compute_sample_stats([f], pos_se, neg_se) + for row in rows: + row["_group_name"] = group_name + row["_group_color"] = group_color + stats_rows.extend(rows) + pw.setValue(len(all_files_with_groups)) + pw.close() + + if not stats_rows: + return + + # Columns to display (exclude internal _ keys) + data_columns = [k for k in stats_rows[0].keys() if not k.startswith("_")] + column_labels = { + "file": "File", + "startTimeStamp": "Start Timestamp", + "ms1_pos": "MS1 Pos", + "ms1_neg": "MS1 Neg", + "ms2_pos": "MS2 Pos", + "ms2_neg": "MS2 Neg", + "last_rt_min": "Last RT (min)", + "ms1_dt_min": "MS1 dT Min (s)", + "ms1_dt_p10": "MS1 dT 10%", + "ms1_dt_p25": "MS1 dT 25%", + "ms1_dt_median": "MS1 dT Median", + "ms1_dt_mean": "MS1 dT Mean", + "ms1_dt_p75": "MS1 dT 75%", + "ms1_dt_p90": "MS1 dT 90%", + "ms1_dt_max": "MS1 dT Max", + "ms1_dt_sd": "MS1 dT SD", + "ms2_dt_min": "MS2 dT Min (s)", + "ms2_dt_p10": "MS2 dT 10%", + "ms2_dt_p25": "MS2 dT 25%", + "ms2_dt_median": "MS2 dT Median", + "ms2_dt_mean": "MS2 dT Mean", + "ms2_dt_p75": "MS2 dT 75%", + "ms2_dt_p90": "MS2 dT 90%", + "ms2_dt_max": "MS2 dT Max", + "ms2_dt_sd": "MS2 dT SD", + "ms1_signalInt_pos_min": "MS1 log10(int) Pos Min", + "ms1_signalInt_pos_p10": "MS1 log10(int) Pos 10%", + "ms1_signalInt_pos_p25": "MS1 log10(int) Pos 25%", + "ms1_signalInt_pos_median": "MS1 log10(int) Pos Median", + "ms1_signalInt_pos_p75": "MS1 log10(int) Pos 75%", + "ms1_signalInt_pos_p90": "MS1 log10(int) Pos 90%", + "ms1_signalInt_pos_p91": "MS1 log10(int) Pos 91%", + "ms1_signalInt_pos_p92": "MS1 log10(int) Pos 92%", + "ms1_signalInt_pos_p93": "MS1 log10(int) Pos 93%", + "ms1_signalInt_pos_p94": "MS1 log10(int) Pos 94%", + "ms1_signalInt_pos_p95": "MS1 log10(int) Pos 95%", + "ms1_signalInt_pos_p96": "MS1 log10(int) Pos 96%", + "ms1_signalInt_pos_p97": "MS1 log10(int) Pos 97%", + "ms1_signalInt_pos_p98": "MS1 log10(int) Pos 98%", + "ms1_signalInt_pos_p99": "MS1 log10(int) Pos 99%", + "ms1_signalInt_pos_max": "MS1 log10(int) Pos Max", + "ms1_signalInt_neg_min": "MS1 log10(int) Neg Min", + "ms1_signalInt_neg_p10": "MS1 log10(int) Neg 10%", + "ms1_signalInt_neg_p25": "MS1 log10(int) Neg 25%", + "ms1_signalInt_neg_median": "MS1 log10(int) Neg Median", + "ms1_signalInt_neg_p75": "MS1 log10(int) Neg 75%", + "ms1_signalInt_neg_p90": "MS1 log10(int) Neg 90%", + "ms1_signalInt_neg_p91": "MS1 log10(int) Neg 91%", + "ms1_signalInt_neg_p92": "MS1 log10(int) Neg 92%", + "ms1_signalInt_neg_p93": "MS1 log10(int) Neg 93%", + "ms1_signalInt_neg_p94": "MS1 log10(int) Neg 94%", + "ms1_signalInt_neg_p95": "MS1 log10(int) Neg 95%", + "ms1_signalInt_neg_p96": "MS1 log10(int) Neg 96%", + "ms1_signalInt_neg_p97": "MS1 log10(int) Neg 97%", + "ms1_signalInt_neg_p98": "MS1 log10(int) Neg 98%", + "ms1_signalInt_neg_p99": "MS1 log10(int) Neg 99%", + "ms1_signalInt_neg_max": "MS1 log10(int) Neg Max", + "MS:1000073": "MS:1000073", + "MS:1000079": "MS:1000079", + } + + # "Group" is the first column; the rest follow + all_columns = ["_group"] + data_columns + all_headers = ["Group"] + [column_labels.get(c, c) for c in data_columns] + + # Compute per-column means for numeric columns (for deviation colour-coding) + numeric_cols = set() + col_values = {c: [] for c in data_columns} + for row in stats_rows: + for c in data_columns: + v = row.get(c) + if v is not None: + try: + col_values[c].append(float(v)) + numeric_cols.add(c) + except (ValueError, TypeError): + pass + col_means = {} + for c in numeric_cols: + vals = col_values[c] + if vals: + col_means[c] = sum(vals) / len(vals) + + def _deviation_color(col, val_str): + """Return a QColor based on fractional deviation from the column mean, or None.""" + if col not in numeric_cols or col not in col_means: + return None + try: + val = float(val_str) + except (ValueError, TypeError): + return None + mean_v = col_means[col] + if mean_v == 0: + return None + dev = abs(val - mean_v) / abs(mean_v) + if dev >= 0.10: + c = QtGui.QColor(178, 34, 34) # firebrick + c.setAlpha(200) + return c + # Gradient: alpha 0 → 200 linearly from dev=0 to dev=0.10 + alpha = int(dev / 0.10 * 200) + c = QtGui.QColor(178, 34, 34) + c.setAlpha(alpha) + return c + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("File Stats") + dialog.resize(1400, 600) + layout = QtWidgets.QVBoxLayout(dialog) + + table = QtWidgets.QTableWidget(len(stats_rows), len(all_columns)) + table.setHorizontalHeaderLabels(all_headers) + table.horizontalHeader().setStretchLastSection(False) + table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + table.setAlternatingRowColors(False) + table.setSortingEnabled(True) + table.setSortingEnabled(False) # disable while populating + + for r, row_data in enumerate(stats_rows): + gname = row_data.get("_group_name", "") + gcolor_str = row_data.get("_group_color", "") + gcolor = QtGui.QColor(gcolor_str) + if gcolor.isValid(): + gcolor.setAlpha(80) + else: + gcolor = None + + # Group column + grp_item = _NumericDateSortItem(gname) + if gcolor: + grp_item.setBackground(gcolor) + table.setItem(r, 0, grp_item) + + # Data columns + for c_idx, col in enumerate(data_columns, start=1): + val = row_data.get(col) + if val is None: + text = "" + elif isinstance(val, float): + text = f"{val:.4f}" + else: + text = str(val) + item = _NumericDateSortItem(text) + dev_color = _deviation_color(col, text) + if dev_color is not None: + item.setBackground(dev_color) + table.setItem(r, c_idx, item) + + table.setSortingEnabled(True) + table.horizontalHeader().setSectionsMovable(True) + table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Interactive) + table.resizeColumnsToContents() + layout.addWidget(table) + + btn_close = QtWidgets.QPushButton("Close") + btn_close.clicked.connect(dialog.accept) + layout.addWidget(btn_close) + dialog.exec() + # show an import group dialog to the user def showAddGroupDialog(self, initWithFiles=[], initWithGroupName=""): tdiag = groupEdit(self, self.lastOpenDir, colors=predefinedColors) @@ -2218,7 +2445,7 @@ def resultsExperimentChanged(self): mean(meanRT) / 60.0 + borderOffset, ] intlim = [intlim[0] * 1.1, intlim[1] * 1.1] - self.drawCanvas(self.ui.resultsExperiment_plot, xlim=rtlim, ylim=intlim) + self.drawCanvas(self.ui.resultsExperiment_plot, xlim=rtlim, ylim=intlim, showLegendOverwrite=False) self.drawCanvas(self.ui.resultsExperimentSeparatedPeaks_plot, showLegendOverwrite=self.ui.showLegend_experiment.isChecked()) lmz_val = plotItems[0].lmz if plotItems[0].lmz else plotItems[0].mz self.drawCanvas( @@ -8719,8 +8946,18 @@ def hasMSMSSpectra(self, mz, rt_min, rt_max, ppm=5.0): return False def updateMSMSList(self, selectedItems): - """Filter and populate MSMS spectra list based on selected features""" - self.ui.msms_SpectraList.clear() + """Filter and populate MSMS spectra table based on selected features""" + + class _NSItem(QTableWidgetItem): + def __lt__(self, other): + try: + return float(self.text()) < float(other.text()) + except (ValueError, TypeError): + return self.text() < other.text() + + tbl = self.ui.msms_SpectraList + tbl.setSortingEnabled(False) + tbl.setRowCount(0) if not hasattr(self, "currentOpenRawFile") or self.currentOpenRawFile is None: return @@ -8734,26 +8971,23 @@ def updateMSMSList(self, selectedItems): if hasattr(item, "myType") and (item.myType == "feature" or item.myType == "Features"): if item.myType == "feature": cp = item.myData - # Get RT range in seconds - rt_min = cp.NPeakCenterMin - cp.NPeakScale - rt_max = cp.NPeakCenterMin + cp.NPeakScale - # Also consider labeled peak RT range - rt_min = min(rt_min, cp.LPeakCenterMin - cp.LPeakScale) - rt_max = max(rt_max, cp.LPeakCenterMin + cp.LPeakScale) - - # Get m/z range with tolerance + # Use actual peak start/end RT (in seconds); fall back to center ± scale + rt_min_n = getattr(cp, "N_startRT", cp.NPeakCenterMin - cp.NPeakScale) + rt_max_n = getattr(cp, "N_endRT", cp.NPeakCenterMin + cp.NPeakScale) + rt_min_l = getattr(cp, "L_startRT", cp.LPeakCenterMin - cp.LPeakScale) + rt_max_l = getattr(cp, "L_endRT", cp.LPeakCenterMin + cp.LPeakScale) + rt_min = min(rt_min_n, rt_min_l) + rt_max = max(rt_max_n, rt_max_l) + try: ppm = float(self.getParametersFromCurrentRes("Mass deviation (+/- ppm)")) except Exception: ppm = 5.0 - # Store native (M) and labeled (M') ranges separately native_mz_min = cp.mz * (1 - ppm / 1000000.0) native_mz_max = cp.mz * (1 + ppm / 1000000.0) - feature_ranges.append({"rt_min": rt_min, "rt_max": rt_max, "mz_min": native_mz_min, "mz_max": native_mz_max, "feature_name": "%.4f @ %.2f min" % (cp.mz, cp.NPeakCenterMin / 60.0), "form": "native"}) - # Add labeled form if available if hasattr(cp, "lmz"): labeled_mz_min = cp.lmz * (1 - ppm / 1000000.0) labeled_mz_max = cp.lmz * (1 + ppm / 1000000.0) @@ -8762,37 +8996,189 @@ def updateMSMSList(self, selectedItems): if not feature_ranges: return - # Filter MS2 spectra within feature ranges matching_ms2 = [] for ms2_scan in self.currentOpenRawFile.MS2_list: for fr in feature_ranges: - # Check if MS2 scan is within RT range if fr["rt_min"] <= ms2_scan.retention_time <= fr["rt_max"]: - # Check if precursor m/z is within feature m/z range if fr["mz_min"] <= ms2_scan.precursor_mz <= fr["mz_max"]: matching_ms2.append({"scan": ms2_scan, "feature_name": fr["feature_name"], "form": fr["form"]}) break - # Populate list widget + _native_color = QtGui.QColor(30, 144, 255, 60) # dodgerblue + _labeled_color = QtGui.QColor(178, 34, 34, 60) # firebrick + for ms2_info in sorted(matching_ms2, key=lambda x: x["scan"].precursor_intensity): scan = ms2_info["scan"] - form_label = "M" if ms2_info["form"] == "native" else "M'" - item_text = "%s: I %.3g, %.4f @ %.2f min" % (form_label, scan.precursor_intensity, scan.precursor_mz, scan.retention_time / 60.0) - list_item = QtWidgets.QListWidgetItem(item_text) - list_item.setData(QtCore.Qt.UserRole, scan) # Store scan object - list_item.setData(QtCore.Qt.UserRole + 1, ms2_info["form"]) # Store form type - self.ui.msms_SpectraList.addItem(list_item) - - # Auto-select last 6 entries - total_items = self.ui.msms_SpectraList.count() - if total_items > 0: - self.ui.msms_SpectraList.item(total_items - 1).setSelected(True) + form_label = "M" if ms2_info["form"] == "native" else "M\u2032" + row_idx = tbl.rowCount() + tbl.insertRow(row_idx) + + nl_item = _NSItem(form_label) + nl_item.setData(QtCore.Qt.UserRole, scan) + nl_item.setData(QtCore.Qt.UserRole + 1, ms2_info["form"]) + tbl.setItem(row_idx, 0, nl_item) + tbl.setItem(row_idx, 1, _NSItem(f"{scan.precursor_intensity:.4g}")) + tbl.setItem(row_idx, 2, _NSItem(f"{scan.precursor_mz:.4f}")) + tbl.setItem(row_idx, 3, _NSItem(f"{scan.retention_time / 60.0:.2f}")) + + row_color = _labeled_color if ms2_info["form"] == "labeled" else _native_color + for col in range(4): + tbl.item(row_idx, col).setBackground(row_color) + + tbl.setSortingEnabled(True) + tbl.resizeColumnsToContents() + + total_rows = tbl.rowCount() + if total_rows > 0: + tbl.selectRow(total_rows - 1) + + def _setup_msms_hover(self, plot_obj): + """Connect hover and click handlers to an MSMS canvas. + + Hover: when the cursor is close to a fragment peak (using normalised 2-D + distance in m/z and intensity), the vline is redrawn 3× thicker and a + popup annotation shows its m/z value. + + Click: same proximity test; if close enough the annotation is *pinned* + and will survive zoom/pan. Pinned annotations are cleared the next time + this method is called (i.e. when new spectra are selected). + + State on plot_obj: + _hover_cid – mpl connection id for motion_notify_event + _click_cid – mpl connection id for button_press_event + _hover_artists – temporary artists removed on next move event + _pinned_artists – persistent artists cleared on next setup call + """ + canvas = plot_obj.canvas + + # Disconnect any previous handlers + for attr in ("_hover_cid", "_click_cid"): + cid = getattr(plot_obj, attr, None) + if cid is not None: + try: + canvas.mpl_disconnect(cid) + except Exception: + pass + plot_obj._hover_cid = None + plot_obj._click_cid = None + plot_obj._hover_artists = [] + + # Clear pinned annotations from previous spectra + for artist in list(getattr(plot_obj, "_pinned_artists", [])): + try: + artist.remove() + except Exception: + pass + plot_obj._pinned_artists = [] + + if not plot_obj.axes: + return + + def _find_closest(ax, mx, my): + """Return (cidx, norm_dist) for the nearest peak using 2-D normalised distance.""" + peaks = getattr(ax, "_msms_peaks", None) + if peaks is None or len(peaks[0]) == 0: + return None, None + mz_arr, int_arr, _ = peaks + xlim = ax.get_xlim() + ylim = ax.get_ylim() + x_range = xlim[1] - xlim[0] or 1.0 + y_range = ylim[1] - ylim[0] or 1.0 + best_idx, best_dist = 0, float("inf") + for i, (mz, iv) in enumerate(zip(mz_arr, int_arr)): + dx = abs(float(mz) - mx) / x_range + dy = abs(float(iv) - my) / y_range + d = (dx**2 + dy**2) ** 0.5 + if d < best_dist: + best_dist = d + best_idx = i + return best_idx, best_dist + + def _on_hover(event): + for artist in list(plot_obj._hover_artists): + try: + artist.remove() + except Exception: + pass + plot_obj._hover_artists.clear() + + if event.inaxes is None or event.xdata is None or event.ydata is None: + canvas.draw_idle() + return + + ax = event.inaxes + peaks = getattr(ax, "_msms_peaks", None) + if peaks is None or len(peaks[0]) == 0: + canvas.draw_idle() + return + + mz_arr, int_arr, spec_color = peaks + cidx, nd = _find_closest(ax, event.xdata, event.ydata) + if nd is None or nd > 0.03: + canvas.draw_idle() + return + + mz_val = float(mz_arr[cidx]) + int_val = float(int_arr[cidx]) + + vl = ax.vlines(mz_val, 0, int_val, colors=spec_color, linewidth=4.5, zorder=5) + plot_obj._hover_artists.append(vl) + + ann = ax.annotate( + "m/z %.4f" % mz_val, + xy=(mz_val, int_val), + xytext=(8, 8), + textcoords="offset points", + fontsize=11, + color="#202124", + bbox=dict(boxstyle="round,pad=0.4", facecolor="lightyellow", edgecolor="#aaaaaa", alpha=0.95), + zorder=10, + ) + plot_obj._hover_artists.append(ann) + canvas.draw_idle() + + def _on_click(event): + if event.inaxes is None or event.xdata is None or event.ydata is None: + return + + ax = event.inaxes + peaks = getattr(ax, "_msms_peaks", None) + if peaks is None or len(peaks[0]) == 0: + return + + mz_arr, int_arr, spec_color = peaks + cidx, nd = _find_closest(ax, event.xdata, event.ydata) + if nd is None or nd > 0.03: + return + + mz_val = float(mz_arr[cidx]) + int_val = float(int_arr[cidx]) + + # Pin a thick vline and labelled annotation that survive zoom/pan + vl = ax.vlines(mz_val, 0, int_val, colors=spec_color, linewidth=4.5, zorder=5) + plot_obj._pinned_artists.append(vl) + + ann = ax.annotate( + "m/z %.4f" % mz_val, + xy=(mz_val, int_val), + xytext=(8, 8), + textcoords="offset points", + fontsize=11, + color="#202124", + bbox=dict(boxstyle="round,pad=0.4", facecolor="lightyellow", edgecolor="#888888", alpha=0.98), + zorder=11, + ) + plot_obj._pinned_artists.append(ann) + canvas.draw_idle() + + plot_obj._hover_cid = canvas.mpl_connect("motion_notify_event", _on_hover) + plot_obj._click_cid = canvas.mpl_connect("button_press_event", _on_click) def plotSelectedMSMSSpectra(self): """Plot selected MSMS spectra as subplots with shared x-axis""" - selected_items = self.ui.msms_SpectraList.selectedItems() + selected_rows = sorted(set(item.row() for item in self.ui.msms_SpectraList.selectedItems())) - if not selected_items: + if not selected_rows: # Clear the plot self.ui.plMSMS.fig.clear() self.ui.plMSMS.axes = [] @@ -8804,15 +9190,18 @@ def plotSelectedMSMSSpectra(self): self.ui.plMSMS.axes = [] # Calculate subplot grid - n_spectra = len(selected_items) - n_cols = min(2, n_spectra) + n_spectra = len(selected_rows) + n_cols = 1 if n_spectra == 2 else min(2, n_spectra) n_rows = (n_spectra + n_cols - 1) // n_cols # Create subplots with shared x-axis first_ax = None - for idx, item in enumerate(selected_items): - scan = item.data(QtCore.Qt.UserRole) - form_type = item.data(QtCore.Qt.UserRole + 1) # Get form type (native/labeled) + for idx, row_idx in enumerate(selected_rows): + col0 = self.ui.msms_SpectraList.item(row_idx, 0) + if col0 is None: + continue + scan = col0.data(QtCore.Qt.UserRole) + form_type = col0.data(QtCore.Qt.UserRole + 1) # Determine color based on form type if form_type == "labeled": @@ -8835,115 +9224,195 @@ def plotSelectedMSMSSpectra(self): if len(scan.mz_list) > 0: ax.vlines(scan.mz_list, 0, scan.intensity_list, colors=spectrum_color, linewidth=1.5) ax.plot(scan.mz_list, scan.intensity_list, "o", markersize=3, color=spectrum_color) + # Store per-axis peak data for hover interactivity + ax._msms_peaks = (scan.mz_list.copy(), scan.intensity_list.copy(), spectrum_color) - # Label the 10 most abundant peaks if len(scan.intensity_list) > 0: - # Get indices of top 10 peaks by intensity - # Create list of (intensity, index) pairs, sort by intensity, get top 10 indices - intensity_with_idx = [(intensity, idx) for idx, intensity in enumerate(scan.intensity_list)] + intensity_with_idx = [(intensity, i) for i, intensity in enumerate(scan.intensity_list)] intensity_with_idx.sort(reverse=True) - top_indices = [idx for _, idx in intensity_with_idx[:10]] + top_indices = [i for _, i in intensity_with_idx[:10]] for peak_idx in top_indices: mz_val = scan.mz_list[peak_idx] intensity_val = scan.intensity_list[peak_idx] - # Add text label above the peak - ax.text(mz_val, intensity_val, "%.4f" % mz_val, fontsize=12, ha="center", va="bottom", rotation=90, color=label_color, alpha=0.3) + ax.text(mz_val, intensity_val * 1.01, "%.4f" % mz_val, fontsize=9, ha="center", va="bottom", rotation=0, color=label_color, alpha=0.6) - # Set labels and title ax.set_xlabel("m/z", fontsize=12) ax.set_ylabel("Intensity", fontsize=12) ax.set_title("Scan %d: %.4f m/z | RT %.2f min | I %.3g" % (scan.id, scan.precursor_mz, scan.retention_time / 60.0, scan.precursor_intensity), fontsize=11) ax.tick_params(labelsize=12) ax.grid(True, alpha=0.3) - # Apply tight layout for optimal spacing try: self.ui.plMSMS.fig.tight_layout() except Exception: - # Fallback to manual adjustment if tight_layout fails self.ui.plMSMS.fig.subplots_adjust(left=0.08, bottom=0.08, right=0.98, top=0.95, hspace=0.4, wspace=0.3) + self._setup_msms_hover(self.ui.plMSMS) self.ui.plMSMS.canvas.draw() def updateMSMSList_exp(self, selectedItems): - """Filter and populate MSMS spectra list for experimental results panel""" - self.ui.msms_SpectraList_exp.clear() + """Filter and populate MSMS spectra table for experimental results panel""" + + class _NSItem(QTableWidgetItem): + def __lt__(self, other): + try: + return float(self.text()) < float(other.text()) + except (ValueError, TypeError): + return self.text() < other.text() + + tbl = self.ui.msms_SpectraList_exp + tbl.setSortingEnabled(False) + tbl.setRowCount(0) if not hasattr(self, "loadedMZXMLs") or self.loadedMZXMLs is None: return - # Get ppm tolerance and RT border offset from UI - try: - ppm = self.ui.doubleSpinBox_resultsExperiment_EICppm.value() - except Exception: - ppm = 5.0 - try: borderOffset = self.ui.doubleSpinBox_resultsExperiment_PeakWidth.value() except Exception: - borderOffset = 0.5 # Default 0.5 minutes + borderOffset = 0.5 + + # Build file-path -> group-name mapping (loadedMZXMLs is keyed by both path and group) + file_to_group = {} + for grp in self.getAllSampleGroups(): + for fpath in grp.files: + file_to_group[fpath] = grp.name + + # Only consider keys that are actual file paths (not group-name aliases) + file_keys = [k for k in self.loadedMZXMLs if k.lower().endswith(".mzxml") or k.lower().endswith(".mzml")] - # Collect RT and m/z ranges from selected features feature_ranges = [] + # Build per-feature, per-sample RT bounds from the results DB when available + rows_by_num = {} + if hasattr(self, "experimentResults") and self.experimentResults is not None and self.experimentResults.db_con is not None: + for sheet in ["4_Convoluted", "3_Reintegrated", "1_Bracketed"]: + try: + tdf = self.experimentResults.db_con.get_table(sheet) + if tdf is not None and not tdf.is_empty(): + rows_by_num = {r["Num"]: r for r in tdf.to_dicts()} + break + except Exception: + pass + for item in selectedItems: if hasattr(item, "bunchData"): bd = item.bunchData if bd.type == "featurePair": - # Get RT range using borderOffset (rt is in seconds) - rt_min = bd.rt - (borderOffset * 60.0) # Convert minutes to seconds - rt_max = bd.rt + (borderOffset * 60.0) + try: + ppm = self.ui.doubleSpinBox_resultsExperiment_EICppm.value() + except Exception: + ppm = 5.0 - # Store native (M) and labeled (M') ranges separately native_mz_min = bd.mz * (1 - ppm / 1000000.0) native_mz_max = bd.mz * (1 + ppm / 1000000.0) - - feature_ranges.append({"rt_min": rt_min, "rt_max": rt_max, "mz_min": native_mz_min, "mz_max": native_mz_max, "feature_name": "%.4f @ %.2f min" % (bd.mz, bd.rt / 60.0), "form": "native"}) - - # Add labeled form labeled_mz_min = bd.lmz * (1 - ppm / 1000000.0) labeled_mz_max = bd.lmz * (1 + ppm / 1000000.0) - feature_ranges.append({"rt_min": rt_min, "rt_max": rt_max, "mz_min": labeled_mz_min, "mz_max": labeled_mz_max, "feature_name": "%.4f @ %.2f min" % (bd.lmz, bd.rt / 60.0), "form": "labeled"}) + + # Build a per-file RT range dict: file_key -> (rt_min_s, rt_max_s) + # Values are in seconds for comparison with ms2_scan.retention_time + per_file_rt = {} + row_data = rows_by_num.get(getattr(bd, "id", None)) + if row_data is not None: + for file_key in file_keys: + fname = os.path.basename(file_key) + for ext in [".mzxml", ".mzml"]: + if fname.lower().endswith(ext): + fname = fname[: -len(ext)] + break + sv = row_data.get(f"{fname}_N_startRT") + ev = row_data.get(f"{fname}_N_endRT") + lsv = row_data.get(f"{fname}_L_startRT") + lev = row_data.get(f"{fname}_L_endRT") + try: + # DB stores RT in minutes; convert to seconds + rt_min_s = min( + float(str(sv).split(";")[0]) * 60.0 if sv is not None else float("inf"), + float(str(lsv).split(";")[0]) * 60.0 if lsv is not None else float("inf"), + ) + rt_max_s = max( + float(str(ev).split(";")[0]) * 60.0 if ev is not None else float("-inf"), + float(str(lev).split(";")[0]) * 60.0 if lev is not None else float("-inf"), + ) + if rt_min_s != float("inf") and rt_max_s != float("-inf"): + per_file_rt[file_key] = (rt_min_s, rt_max_s) + except (TypeError, ValueError): + pass + + feature_ranges.append( + { + "native_mz_min": native_mz_min, + "native_mz_max": native_mz_max, + "labeled_mz_min": labeled_mz_min, + "labeled_mz_max": labeled_mz_max, + "per_file_rt": per_file_rt, + "global_rt": bd.rt, # seconds, used as fallback + } + ) if not feature_ranges: return - # Collect MS2 scans from all loaded files all_ms2_scans = [] - for file_key, mzxml_file in self.loadedMZXMLs.items(): - if hasattr(mzxml_file, "MS2_list") and len(mzxml_file.MS2_list) > 0: - for ms2_scan in mzxml_file.MS2_list: - # Check if this scan matches any feature range - for fr in feature_ranges: - if fr["rt_min"] <= ms2_scan.retention_time <= fr["rt_max"]: - if fr["mz_min"] <= ms2_scan.precursor_mz <= fr["mz_max"]: - all_ms2_scans.append({"scan": ms2_scan, "form": fr["form"], "file": file_key}) - break - - # Add to list widget - sort by filename then RT using natural sort - temp_list = [] - for scan_info in all_ms2_scans: - scan = scan_info["scan"] - form = scan_info["form"] - file_key = scan_info["file"] - - # Extract filename from path + for file_key in file_keys: + mzxml_file = self.loadedMZXMLs[file_key] + if not (hasattr(mzxml_file, "MS2_list") and len(mzxml_file.MS2_list) > 0): + continue + for ms2_scan in mzxml_file.MS2_list: + for fr in feature_ranges: + # Determine RT bounds for this specific file + if file_key in fr["per_file_rt"]: + rt_min_s, rt_max_s = fr["per_file_rt"][file_key] + else: + # Fallback: borderOffset around the global feature RT + rt_min_s = fr["global_rt"] - (borderOffset * 60.0) + rt_max_s = fr["global_rt"] + (borderOffset * 60.0) + + if not (rt_min_s <= ms2_scan.retention_time <= rt_max_s): + continue + + # Check precursor m/z against native form + if fr["native_mz_min"] <= ms2_scan.precursor_mz <= fr["native_mz_max"]: + all_ms2_scans.append({"scan": ms2_scan, "form": "native", "file": file_key}) + break + # Check against labeled form + if fr["labeled_mz_min"] <= ms2_scan.precursor_mz <= fr["labeled_mz_max"]: + all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key}) + break + + temp_list = [(s["scan"], s["form"], s["file"]) for s in all_ms2_scans] + temp_list = natSort(temp_list, key=lambda x: x[0].precursor_intensity) + + _native_color = QtGui.QColor(30, 144, 255, 60) + _labeled_color = QtGui.QColor(178, 34, 34, 60) + + for scan, form, file_key in temp_list: + form_label = "M\u2032" if form == "labeled" else "M" + row_idx = tbl.rowCount() + tbl.insertRow(row_idx) + + group_name = file_to_group.get(file_key, "") filename = os.path.basename(file_key) - temp_list.append((scan, form, filename)) - # Sort by filename (natural sort) then by retention time - temp_list = natSort(temp_list, key=lambda x: (x[0].precursor_intensity)) + nl_item = _NSItem(form_label) + nl_item.setData(QtCore.Qt.UserRole, scan) + nl_item.setData(QtCore.Qt.UserRole + 1, form) + tbl.setItem(row_idx, 0, nl_item) + tbl.setItem(row_idx, 1, _NSItem(f"{scan.precursor_intensity:.4g}")) + tbl.setItem(row_idx, 2, _NSItem(f"{scan.precursor_mz:.4f}")) + tbl.setItem(row_idx, 3, _NSItem(f"{scan.retention_time / 60.0:.2f}")) + tbl.setItem(row_idx, 4, _NSItem(group_name)) + tbl.setItem(row_idx, 5, _NSItem(filename)) + + row_color = _labeled_color if form == "labeled" else _native_color + for col in range(6): + tbl.item(row_idx, col).setBackground(row_color) - for scan, form, filename in temp_list: - item_text = "%s: I %.3g, %.4f @ %.2f, %s" % ("M'" if form == "labeled" else "M", scan.precursor_intensity, scan.precursor_mz, scan.retention_time / 60.0, filename) - item = QtWidgets.QListWidgetItem(item_text) - item.setData(QtCore.Qt.UserRole, scan) - item.setData(QtCore.Qt.UserRole + 1, form) - self.ui.msms_SpectraList_exp.addItem(item) + tbl.setSortingEnabled(True) + tbl.resizeColumnsToContents() - # Auto-select last 6 entries - total_items = self.ui.msms_SpectraList_exp.count() - if total_items > 0: - self.ui.msms_SpectraList_exp.item(total_items - 1).setSelected(True) + total_rows = tbl.rowCount() + if total_rows > 0: + tbl.selectRow(total_rows - 1) def updatePeakDetailsTab(self, plotItems): """Populate the peak details tab tables for the selected features.""" @@ -9317,39 +9786,36 @@ def _grp_cell(text, color=grp_color): def plotSelectedMSMSSpectra_exp(self): """Plot selected MSMS spectra from experimental results panel""" - selected_items = self.ui.msms_SpectraList_exp.selectedItems() + selected_rows = sorted(set(item.row() for item in self.ui.msms_SpectraList_exp.selectedItems())) - if not selected_items: - # Clear the plot + if not selected_rows: self.ui.plMSMS_exp.fig.clear() self.ui.plMSMS_exp.axes = [] self.ui.plMSMS_exp.canvas.draw() return - # Clear previous plots self.ui.plMSMS_exp.fig.clear() self.ui.plMSMS_exp.axes = [] - # Calculate subplot grid - n_spectra = len(selected_items) - n_cols = min(2, n_spectra) + n_spectra = len(selected_rows) + n_cols = 1 if n_spectra == 2 else min(2, n_spectra) n_rows = (n_spectra + n_cols - 1) // n_cols - # Create subplots with shared x-axis first_ax = None - for idx, item in enumerate(selected_items): - scan = item.data(QtCore.Qt.UserRole) - form_type = item.data(QtCore.Qt.UserRole + 1) + for idx, row_idx in enumerate(selected_rows): + col0 = self.ui.msms_SpectraList_exp.item(row_idx, 0) + if col0 is None: + continue + scan = col0.data(QtCore.Qt.UserRole) + form_type = col0.data(QtCore.Qt.UserRole + 1) - # Determine color based on form type if form_type == "labeled": spectrum_color = "firebrick" label_color = "darkred" - else: # native + else: spectrum_color = "dodgerblue" label_color = "darkblue" - # Create subplot with shared x-axis if idx == 0: ax = self.ui.plMSMS_exp.fig.add_subplot(n_rows, n_cols, idx + 1) first_ax = ax @@ -9358,34 +9824,33 @@ def plotSelectedMSMSSpectra_exp(self): self.ui.plMSMS_exp.axes.append(ax) - # Plot MS/MS spectrum as stem plot with form-specific color if len(scan.mz_list) > 0: ax.vlines(scan.mz_list, 0, scan.intensity_list, colors=spectrum_color, linewidth=1.5) ax.plot(scan.mz_list, scan.intensity_list, "o", markersize=3, color=spectrum_color) + # Store per-axis peak data for hover interactivity + ax._msms_peaks = (scan.mz_list.copy(), scan.intensity_list.copy(), spectrum_color) - # Label the 10 most abundant peaks if len(scan.intensity_list) > 0: - intensity_with_idx = [(intensity, idx) for idx, intensity in enumerate(scan.intensity_list)] + intensity_with_idx = [(intensity, i) for i, intensity in enumerate(scan.intensity_list)] intensity_with_idx.sort(reverse=True) - top_indices = [idx for _, idx in intensity_with_idx[:10]] + top_indices = [i for _, i in intensity_with_idx[:10]] for peak_idx in top_indices: mz_val = scan.mz_list[peak_idx] intensity_val = scan.intensity_list[peak_idx] - ax.text(mz_val, intensity_val, "%.4f" % mz_val, fontsize=12, ha="center", va="bottom", rotation=90, color=label_color, alpha=0.3) + ax.text(mz_val, intensity_val * 1.01, "%.4f" % mz_val, fontsize=9, ha="center", va="bottom", rotation=0, color=label_color, alpha=0.6) - # Set labels and title ax.set_xlabel("m/z", fontsize=12) ax.set_ylabel("Intensity", fontsize=12) ax.set_title("Scan %d: %.4f m/z | RT %.2f min | I %.3g" % (scan.id, scan.precursor_mz, scan.retention_time / 60.0, scan.precursor_intensity), fontsize=11) ax.tick_params(labelsize=12) ax.grid(True, alpha=0.3) - # Apply tight layout for optimal spacing try: self.ui.plMSMS_exp.fig.tight_layout() except Exception: self.ui.plMSMS_exp.fig.subplots_adjust(left=0.08, bottom=0.08, right=0.98, top=0.95, hspace=0.4, wspace=0.3) + self._setup_msms_hover(self.ui.plMSMS_exp) self.ui.plMSMS_exp.canvas.draw() # @@ -10807,7 +11272,7 @@ def _delete_saved(): mean(meanRT) / 60.0 + borderOffset, ] intlim = [intlim[0] * 1.1, intlim[1] * 1.1] - self.drawCanvas(self.ui.resultsExperiment_plot, xlim=rtlim, ylim=intlim) + self.drawCanvas(self.ui.resultsExperiment_plot, xlim=rtlim, ylim=intlim, showLegendOverwrite=False) self.drawCanvas(self.ui.resultsExperimentSeparatedPeaks_plot, showLegendOverwrite=self.ui.showLegend_experiment.isChecked()) self.drawCanvas( self.ui.resultsExperimentMSScanPeaks_plot, @@ -11682,6 +12147,7 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal self.ui.saveGroups.clicked.connect(self.saveGroupsClicked) self.ui.loadGroups.clicked.connect(self.loadGroupsClicked) self.ui.removeGroup.clicked.connect(self.remGrp) + self.ui.showFileStats.clicked.connect(self.showFileStatsPopup) self.ui.groupsList.doubleClicked.connect(self.editGroup) self.ui.groupsList.itemChanged.connect(self._onGroupTableItemChanged) diff --git a/src/bracketResults.py b/src/bracketResults.py index 28dfe17..273e436 100644 --- a/src/bracketResults.py +++ b/src/bracketResults.py @@ -1,17 +1,20 @@ from __future__ import absolute_import, division, print_function import base64 import datetime +import hashlib import json import logging import os import statistics import time import uuid +import xml.etree.ElementTree as _ET from collections import OrderedDict, defaultdict -from math import isnan +from math import isnan, log from multiprocessing import Manager, Pool from operator import itemgetter from pickle import loads as pickle_loads +import numpy as np import polars as pl from reportlab.graphics import renderPDF from reportlab.graphics.charts.lineplots import LinePlot @@ -35,7 +38,55 @@ sd, ) from .XICAlignment import XICAlignment -from .utils import mapArrayToRefTimes +from .utils import mapArrayToRefTimes, get_app_folder + + +def log10(x): + return log(x, 10) + + +# --------------------------------------------------------------------------- +# Per-file sample-stats cache helpers +# --------------------------------------------------------------------------- + + +def _get_stats_cache_dir(): + cache_dir = os.path.join(get_app_folder(), "sampleStats") + os.makedirs(cache_dir, exist_ok=True) + return cache_dir + + +def _load_cached_stats(filepath, scan_event_key): + """Return cached stats dict for *filepath* or None if missing / stale.""" + try: + fp = os.path.abspath(filepath) + key = hashlib.md5(fp.encode("utf-8")).hexdigest() + cache_path = os.path.join(_get_stats_cache_dir(), f"{key}.json") + if not os.path.exists(cache_path): + return None + mtime = os.path.getmtime(fp) + with open(cache_path, "r", encoding="utf-8") as fh: + cached = json.load(fh) + if cached.get("_mtime") != mtime: + return None + if cached.get("_scan_event_key") != scan_event_key: + return None + return cached.get("data") + except Exception: + return None + + +def _save_cached_stats(filepath, data, scan_event_key): + """Write stats dict to the per-file cache.""" + try: + fp = os.path.abspath(filepath) + key = hashlib.md5(fp.encode("utf-8")).hexdigest() + cache_path = os.path.join(_get_stats_cache_dir(), f"{key}.json") + mtime = os.path.getmtime(fp) + with open(cache_path, "w", encoding="utf-8") as fh: + json.dump({"_mtime": mtime, "_scan_event_key": scan_event_key, "data": data}, fh) + except Exception as e: + logging.debug(f"Could not save stats cache for {filepath}: {e}") # HELPER METHOD for writing first page of PDF (unused) @@ -118,6 +169,204 @@ def writeConfigToDB( db.insert_row("config", {"key": "FPBRACK_nPolynom", "value": str(nPolynom)}) +def _get_mzml_metadata(filepath): + """Extract startTimeStamp and specific cvParams from an mzML header via streaming XML parsing.""" + result = {"startTimeStamp": None, "MS:1000073": None, "MS:1000079": None} + ns = {"mzml": "http://psi.hupo.org/ms/mzml"} + try: + for event, elem in _ET.iterparse(filepath, events=("start",)): + tag = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + if tag == "run": + result["startTimeStamp"] = elem.get("startTimeStamp") + elif tag == "cvParam": + acc = elem.get("accession", "") + if acc in ("MS:1000073", "MS:1000079"): + result[acc] = elem.get("name", acc) + # Stop after the first spectrum to avoid reading the whole file + elif tag == "spectrum": + break + except Exception as e: + logging.debug(f"Could not parse mzML metadata from {filepath}: {e}") + return result + + +def _percentile_stats(diffs): + """Return a dict of percentile statistics for a list of float values.""" + if not diffs: + return {k: None for k in ("min", "p10", "p25", "median", "mean", "p75", "p90", "max", "sd")} + arr = np.array(diffs, dtype=float) + return { + "min": float(np.min(arr)), + "p10": float(np.percentile(arr, 10)), + "p25": float(np.percentile(arr, 25)), + "median": float(np.median(arr)), + "mean": float(np.mean(arr)), + "p75": float(np.percentile(arr, 75)), + "p90": float(np.percentile(arr, 90)), + "max": float(np.max(arr)), + "sd": float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0, + } + + +def _intensity_percentile_stats(intensities): + """Return intensity distribution stats (min, p10, p25, median, p75, p90–p99, max).""" + _keys = ("min", "p10", "p25", "median", "p75", "p90", "p91", "p92", "p93", "p94", "p95", "p96", "p97", "p98", "p99", "max") + if not intensities: + return {k: None for k in _keys} + arr = np.array(intensities, dtype=float) + return { + "min": float(np.min(arr)), + "p10": float(np.percentile(arr, 10)), + "p25": float(np.percentile(arr, 25)), + "median": float(np.median(arr)), + "p75": float(np.percentile(arr, 75)), + "p90": float(np.percentile(arr, 90)), + "p91": float(np.percentile(arr, 91)), + "p92": float(np.percentile(arr, 92)), + "p93": float(np.percentile(arr, 93)), + "p94": float(np.percentile(arr, 94)), + "p95": float(np.percentile(arr, 95)), + "p96": float(np.percentile(arr, 96)), + "p97": float(np.percentile(arr, 97)), + "p98": float(np.percentile(arr, 98)), + "p99": float(np.percentile(arr, 99)), + "max": float(np.max(arr)), + } + + +def compute_sample_stats(all_files, positiveScanEvent=None, negativeScanEvent=None): + """Compute per-file sample statistics for a list of raw LC-MS/MS files. + + Returns a list of dicts, one per file, with keys: + file, ms1_pos, ms1_neg, ms2_pos, ms2_neg, last_rt, + ms1_timediff_*, ms2_timediff_*, + startTimeStamp, MS:1000073, MS:1000079 + """ + rows = [] + for filepath in all_files: + filepath = str(filepath).replace("\\", "/") + # v3: compute MS1 intensity stats from actual signal data (intensity_list) + scan_event_key = f"{positiveScanEvent}|{negativeScanEvent}|v6" + + # Try cache first + cached_row = _load_cached_stats(filepath, scan_event_key) + if cached_row is not None: + rows.append(cached_row) + continue + + try: + chrom = Chromatogram() + chrom.parse_file(filepath, ignoreCharacterData=False) + except Exception as e: + logging.warning(f"compute_sample_stats: could not parse {filepath}: {e}") + rows.append({"file": os.path.basename(filepath)}) + continue + + pos_se = positiveScanEvent if positiveScanEvent and positiveScanEvent != "None" else None + neg_se = negativeScanEvent if negativeScanEvent and negativeScanEvent != "None" else None + + # MS1 scan counts per polarity / selected scan event + ms1_pos_scans = [s for s in chrom.MS1_list if s.polarity == "+" and (pos_se is None or s.filter_line == pos_se)] + ms1_neg_scans = [s for s in chrom.MS1_list if s.polarity == "-" and (neg_se is None or s.filter_line == neg_se)] + ms1_pos = len(ms1_pos_scans) + ms1_neg = len(ms1_neg_scans) + + # MS2 scan counts per polarity + ms2_pos = sum(1 for s in chrom.MS2_list if s.polarity == "+") + ms2_neg = sum(1 for s in chrom.MS2_list if s.polarity == "-") + + # Adjacent MS1 time differences (selected scan events only) + ms1_times = sorted(s.retention_time for s in chrom.MS1_list if (pos_se is None or s.filter_line == pos_se or s.polarity == "+") and (neg_se is None or s.filter_line == neg_se or s.polarity == "-")) + if pos_se: + ms1_times = sorted(s.retention_time for s in chrom.MS1_list if s.filter_line == pos_se or s.filter_line == neg_se) + ms1_diffs = [ms1_times[i + 1] - ms1_times[i] for i in range(len(ms1_times) - 1)] + + # Adjacent MS2 time differences + ms2_times = sorted(s.retention_time for s in chrom.MS2_list) + ms2_diffs = [ms2_times[i + 1] - ms2_times[i] for i in range(len(ms2_times) - 1)] + + # Last scan RT (max of all scans) + all_rts = [s.retention_time for s in chrom.MS1_list] + [s.retention_time for s in chrom.MS2_list] + last_rt = max(all_rts) / 60.0 if all_rts else None + + ms1_stats = _percentile_stats(ms1_diffs) + ms2_stats = _percentile_stats(ms2_diffs) + + # Collect ALL individual signal intensities from the actual peak lists + ms1_signalInt_pos = _intensity_percentile_stats([log10(v) for s in ms1_pos_scans for v in s.intensity_list.tolist()]) + ms1_signalInt_neg = _intensity_percentile_stats([log10(v) for s in ms1_neg_scans for v in s.intensity_list.tolist()]) + + # mzML-specific metadata + mzml_meta = {"startTimeStamp": None, "MS:1000073": None, "MS:1000079": None} + if filepath.lower().endswith(".mzml"): + mzml_meta = _get_mzml_metadata(filepath) + + row = { + "file": os.path.basename(filepath), + "startTimeStamp": mzml_meta["startTimeStamp"], + "ms1_pos": ms1_pos, + "ms1_neg": ms1_neg, + "ms2_pos": ms2_pos, + "ms2_neg": ms2_neg, + "last_rt_min": last_rt, + "ms1_dt_min": ms1_stats["min"], + "ms1_dt_p10": ms1_stats["p10"], + "ms1_dt_p25": ms1_stats["p25"], + "ms1_dt_median": ms1_stats["median"], + "ms1_dt_mean": ms1_stats["mean"], + "ms1_dt_p75": ms1_stats["p75"], + "ms1_dt_p90": ms1_stats["p90"], + "ms1_dt_max": ms1_stats["max"], + "ms1_dt_sd": ms1_stats["sd"], + "ms2_dt_min": ms2_stats["min"], + "ms2_dt_p10": ms2_stats["p10"], + "ms2_dt_p25": ms2_stats["p25"], + "ms2_dt_median": ms2_stats["median"], + "ms2_dt_mean": ms2_stats["mean"], + "ms2_dt_p75": ms2_stats["p75"], + "ms2_dt_p90": ms2_stats["p90"], + "ms2_dt_max": ms2_stats["max"], + "ms2_dt_sd": ms2_stats["sd"], + "ms1_signalInt_pos_min": ms1_signalInt_pos["min"], + "ms1_signalInt_pos_p10": ms1_signalInt_pos["p10"], + "ms1_signalInt_pos_p25": ms1_signalInt_pos["p25"], + "ms1_signalInt_pos_median": ms1_signalInt_pos["median"], + "ms1_signalInt_pos_p75": ms1_signalInt_pos["p75"], + "ms1_signalInt_pos_p90": ms1_signalInt_pos["p90"], + "ms1_signalInt_pos_p91": ms1_signalInt_pos["p91"], + "ms1_signalInt_pos_p92": ms1_signalInt_pos["p92"], + "ms1_signalInt_pos_p93": ms1_signalInt_pos["p93"], + "ms1_signalInt_pos_p94": ms1_signalInt_pos["p94"], + "ms1_signalInt_pos_p95": ms1_signalInt_pos["p95"], + "ms1_signalInt_pos_p96": ms1_signalInt_pos["p96"], + "ms1_signalInt_pos_p97": ms1_signalInt_pos["p97"], + "ms1_signalInt_pos_p98": ms1_signalInt_pos["p98"], + "ms1_signalInt_pos_p99": ms1_signalInt_pos["p99"], + "ms1_signalInt_pos_max": ms1_signalInt_pos["max"], + "ms1_signalInt_neg_min": ms1_signalInt_neg["min"], + "ms1_signalInt_neg_p10": ms1_signalInt_neg["p10"], + "ms1_signalInt_neg_p25": ms1_signalInt_neg["p25"], + "ms1_signalInt_neg_median": ms1_signalInt_neg["median"], + "ms1_signalInt_neg_p75": ms1_signalInt_neg["p75"], + "ms1_signalInt_neg_p90": ms1_signalInt_neg["p90"], + "ms1_signalInt_neg_p91": ms1_signalInt_neg["p91"], + "ms1_signalInt_neg_p92": ms1_signalInt_neg["p92"], + "ms1_signalInt_neg_p93": ms1_signalInt_neg["p93"], + "ms1_signalInt_neg_p94": ms1_signalInt_neg["p94"], + "ms1_signalInt_neg_p95": ms1_signalInt_neg["p95"], + "ms1_signalInt_neg_p96": ms1_signalInt_neg["p96"], + "ms1_signalInt_neg_p97": ms1_signalInt_neg["p97"], + "ms1_signalInt_neg_p98": ms1_signalInt_neg["p98"], + "ms1_signalInt_neg_p99": ms1_signalInt_neg["p99"], + "ms1_signalInt_neg_max": ms1_signalInt_neg["max"], + "MS:1000073": mzml_meta["MS:1000073"], + "MS:1000079": mzml_meta["MS:1000079"], + } + _save_cached_stats(filepath, row, scan_event_key) + rows.append(row) + return rows + + # bracket results def bracketResults( indGroups, @@ -1085,6 +1334,20 @@ def bracketResults( excel_file = file.replace(".tsv", ".xlsx") plDB = PolarsDB(excel_file, format="xlsx") + + # Collect all file paths for sample stats + all_files = [] + for key in natSort(indGroups.keys()): + for ident in indGroups[key]: + all_files.append(ident) + sample_stats_rows = compute_sample_stats(all_files, positiveScanEvent, negativeScanEvent) + if sample_stats_rows: + # Build a uniform schema across all rows + all_keys = list(sample_stats_rows[0].keys()) + stats_dict = {k: [row.get(k) for row in sample_stats_rows] for k in all_keys} + sample_stats_df = pl.DataFrame(stats_dict) + plDB.insert_table("0_sampleStats", sample_stats_df) + plDB.insert_table("Parameters", params_df) plDB.insert_table("1_Bracketed", df) plDB.commit() diff --git a/src/findIsoPairs.py b/src/findIsoPairs.py index c9b9e8b..37b3194 100644 --- a/src/findIsoPairs.py +++ b/src/findIsoPairs.py @@ -31,7 +31,7 @@ from .PolarsDB import PolarsDB from .findIsoPairs_matchPartners import matchPartners from .SGR import SGRGenerator -from .chromPeakPicking.peakpickers import filter_peaks +from .chromPeakPicking.peakpickers import BasePeakPicker, filter_peaks import numpy as np import polars as pl import scipy @@ -1168,6 +1168,22 @@ def findChromatographicPeaksAndWriteToDB(self, mzbins, mzxml, tracerID, reportFu peaksN = filter_peaks(peaksN, config=self.peak_filter_config) peaksL = filter_peaks(peaksL, config=self.peak_filter_config) + # Recalculate SNR, FWHM, area from raw (unsmoothed) EICs + # Peak boundaries are kept from smoothed-EIC detection, only metrics are recomputed + times_arr = np.asarray(times) + eic_raw_arr = np.asarray(eic) + eicL_raw_arr = np.asarray(eicL) + for pk in peaksN: + pk.snr = BasePeakPicker.compute_snr(eic_raw_arr, pk.apex_index, pk.start_index, pk.end_index) + pk.fwhm = BasePeakPicker.compute_fwhm(times_arr, eic_raw_arr, pk.apex_index, pk.start_index, pk.end_index) + pk.area = BasePeakPicker.compute_area(times_arr, eic_raw_arr, pk.start_index, pk.end_index) + pk.apex_intensity = float(eic_raw_arr[pk.apex_index]) + for pk in peaksL: + pk.snr = BasePeakPicker.compute_snr(eicL_raw_arr, pk.apex_index, pk.start_index, pk.end_index) + pk.fwhm = BasePeakPicker.compute_fwhm(times_arr, eicL_raw_arr, pk.apex_index, pk.start_index, pk.end_index) + pk.area = BasePeakPicker.compute_area(times_arr, eicL_raw_arr, pk.start_index, pk.end_index) + pk.apex_intensity = float(eicL_raw_arr[pk.apex_index]) + # get EICs of M+1, M'-1 and M'+1 for the database eicfirstiso, timesL, scanIdsL, mzsfirstiso = mzxml.getEIC( meanmz + 1.00335484 / loading, @@ -1846,6 +1862,12 @@ def annotateFeaturePairs(self, chromPeaks, mzxml, tracer, reportFunction=None): peak = chromPeaks[i] ## Annotate hetero atoms + scanEvent = "" + if peak.ionMode == "+": + scanEvent = self.positiveScanEvent + elif peak.ionMode == "-": + scanEvent = self.negativeScanEvent + for pIso in self.heteroAtoms: pIsotope = self.heteroAtoms[pIso] @@ -1861,12 +1883,6 @@ def annotateFeaturePairs(self, chromPeaks, mzxml, tracer, reportFunction=None): else: refMz = peak.lmz - scanEvent = "" - if peak.ionMode == "+": - scanEvent = self.positiveScanEvent - elif peak.ionMode == "-": - scanEvent = self.negativeScanEvent - for haCount in range(pIsotope.minCount, pIsotope.maxCount + 1): if haCount == 0: continue diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 1aae871..1633db2 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -180,6 +180,19 @@ def setupUi(self, MainWindow): self.removeGroup.setIcon(icon2) self.removeGroup.setObjectName(_fromUtf8("removeGroup")) self.horizontalLayout_4.addWidget(self.removeGroup) + self.line_fileStats = QtWidgets.QFrame(self.inputTab) + self.line_fileStats.setFrameShape(QtWidgets.QFrame.VLine) + self.line_fileStats.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_fileStats.setObjectName(_fromUtf8("line_fileStats")) + self.horizontalLayout_4.addWidget(self.line_fileStats) + self.showFileStats = QtWidgets.QPushButton(self.inputTab) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.showFileStats.sizePolicy().hasHeightForWidth()) + self.showFileStats.setSizePolicy(sizePolicy) + self.showFileStats.setObjectName(_fromUtf8("showFileStats")) + self.horizontalLayout_4.addWidget(self.showFileStats) self.gridLayout_22.addLayout(self.horizontalLayout_4, 14, 4, 1, 1) self.line_28 = QtWidgets.QFrame(self.inputTab) self.line_28.setFrameShape(QtWidgets.QFrame.HLine) @@ -2386,7 +2399,7 @@ def setupUi(self, MainWindow): self.label_msms.setObjectName(_fromUtf8("label_msms")) self.gridLayout_msms_list.addWidget(self.label_msms, 0, 0, 1, 1) - self.msms_SpectraList = QtWidgets.QListWidget(self.msms_list_container) + self.msms_SpectraList = QtWidgets.QTableWidget(self.msms_list_container) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -2394,6 +2407,13 @@ def setupUi(self, MainWindow): self.msms_SpectraList.setSizePolicy(sizePolicy) self.msms_SpectraList.setMinimumSize(QtCore.QSize(300, 0)) self.msms_SpectraList.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.msms_SpectraList.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.msms_SpectraList.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.msms_SpectraList.setColumnCount(4) + self.msms_SpectraList.setHorizontalHeaderLabels(["N/L", "Prec. Abundance", "Prec. m/z", "RT (min)"]) + self.msms_SpectraList.horizontalHeader().setStretchLastSection(True) + self.msms_SpectraList.verticalHeader().setVisible(False) + self.msms_SpectraList.setSortingEnabled(True) self.msms_SpectraList.setObjectName(_fromUtf8("msms_SpectraList")) self.gridLayout_msms_list.addWidget(self.msms_SpectraList, 1, 0, 1, 1) @@ -2646,9 +2666,16 @@ def setupUi(self, MainWindow): self.label_msms_exp = QtWidgets.QLabel(self.msms_list_container_exp) self.label_msms_exp.setObjectName(_fromUtf8("label_msms_exp")) self.verticalLayout_msms_exp.addWidget(self.label_msms_exp) - self.msms_SpectraList_exp = QtWidgets.QListWidget(self.msms_list_container_exp) + self.msms_SpectraList_exp = QtWidgets.QTableWidget(self.msms_list_container_exp) self.msms_SpectraList_exp.setMinimumSize(QtCore.QSize(200, 0)) self.msms_SpectraList_exp.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.msms_SpectraList_exp.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.msms_SpectraList_exp.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.msms_SpectraList_exp.setColumnCount(6) + self.msms_SpectraList_exp.setHorizontalHeaderLabels(["N/L", "Prec. Abundance", "Prec. m/z", "RT (min)", "Group", "File"]) + self.msms_SpectraList_exp.horizontalHeader().setStretchLastSection(True) + self.msms_SpectraList_exp.verticalHeader().setVisible(False) + self.msms_SpectraList_exp.setSortingEnabled(True) self.msms_SpectraList_exp.setObjectName(_fromUtf8("msms_SpectraList_exp")) self.verticalLayout_msms_exp.addWidget(self.msms_SpectraList_exp) self.plMSMSWidget_exp = QtWidgets.QWidget(self.splitter_msms_exp) @@ -2956,6 +2983,7 @@ def retranslateUi(self, MainWindow): self.loadGroups.setText(_translate("MainWindow", "Load Groups", None)) self.addGroup.setText(_translate("MainWindow", "New group", None)) self.removeGroup.setText(_translate("MainWindow", "Delete group", None)) + self.showFileStats.setText(_translate("MainWindow", "File Stats", None)) self.label_60.setText(_translate("MainWindow", "Comments", None)) self.label_62.setText(_translate("MainWindow", "Please describe the performed experiment", None)) self.label_56.setText(_translate("MainWindow", "Positive", None)) diff --git a/src/mePyGuis/statisticsTab.py b/src/mePyGuis/statisticsTab.py index 4239998..6693c62 100644 --- a/src/mePyGuis/statisticsTab.py +++ b/src/mePyGuis/statisticsTab.py @@ -1003,7 +1003,11 @@ def _show_rsd_plot(self): rsd_clean = rsd[~np.isnan(rsd)] if len(rsd_clean) > 0: - ax.hist(rsd_clean, bins=30, alpha=0.7, color=group_colors[group_name], edgecolor="black") + n_bins = min(30, max(1, len(np.unique(rsd_clean)))) + try: + ax.hist(rsd_clean, bins=n_bins, alpha=0.7, color=group_colors[group_name], edgecolor="black") + except ValueError: + ax.hist(rsd_clean, bins=1, alpha=0.7, color=group_colors[group_name], edgecolor="black") ax.set_xlabel("RSD (%)", fontsize=8) ax.set_ylabel("Frequency", fontsize=8) @@ -1169,10 +1173,10 @@ def _show_pca(self): ax.set_title(f"PCA Score Plot ({self.stats_data.num_features_used} features)") ax.grid(True, alpha=0.3) - # Add legend + # Add legend outside the plot area on the right side legend_handles = [plt.Line2D([0], [0], marker="o", color="w", markerfacecolor=color, markersize=10, label=group) for group, color in group_colors.items()] - ax.legend(handles=legend_handles, loc="best") - canvas.fig.tight_layout() + ax.legend(handles=legend_handles, loc="upper left", bbox_to_anchor=(1.01, 1), borderaxespad=0, fontsize=8) + canvas.fig.tight_layout(rect=[0, 0, 0.82, 1]) self.viz_layout.addWidget(toolbar) self.viz_layout.addWidget(canvas) diff --git a/src/style.css b/src/style.css index 18eb646..b119075 100644 --- a/src/style.css +++ b/src/style.css @@ -458,6 +458,7 @@ QProgressBar { text-align: center; color: #202124; font-size: 11px; + min-height: 22px; } QProgressBar::chunk { From 33e6c64a43eaeaaf434c0fbe63563ba2926bf574 Mon Sep 17 00:00:00 2001 From: chrboku Date: Fri, 15 May 2026 13:52:36 +0200 Subject: [PATCH 19/27] minor adaptions --- src/MExtract.py | 40 +- src/annotateResultMatrix.py | 19 +- src/mePyGuis/guis/mainwindow.ui | 6389 ------------------ src/mePyGuis/mainWindow.py | 21 +- src/reIntegration.py | 7 +- src/resultsPostProcessing/searchDatabases.py | 137 +- 6 files changed, 121 insertions(+), 6492 deletions(-) delete mode 100644 src/mePyGuis/guis/mainwindow.ui diff --git a/src/MExtract.py b/src/MExtract.py index abac593..954acc3 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -4056,6 +4056,23 @@ def runProcess(self, dontSave=False, askStarting=True): resFileFull = str(self.ui.groupsSave.text()) resFilePath, resFileName = os.path.split(os.path.abspath(resFileFull)) excel_file = resFileFull.replace(".xlsx", ".tsv").replace(".tsv", ".txt").replace(".txt", "") + ".xlsx" + + # Determine the best available input sheet for annotation when only the annotation step is run + # (will be overridden later if bracketing/grouping/re-integration runs as part of this execution) + annotation_input_sheet = "2_StatColumns" + if os.path.isfile(excel_file): + try: + import openpyxl as _opxl_detect + + _wb_detect = _opxl_detect.load_workbook(excel_file, read_only=True) + for _candidate in ("4_Convoluted", "3_Reintegrated", "2_StatColumns"): + if _candidate in _wb_detect.sheetnames: + annotation_input_sheet = _candidate + break + _wb_detect.close() + except Exception: + pass + # bracket/group from individual LC-HRMS data / re-integrate missed peaks if self.ui.processMultipleFiles.checkState() == QtCore.Qt.Checked: pw = ProgressWrapper(1, parent=self) @@ -11615,23 +11632,24 @@ def loadAvailableSettingsFile(self, file): pass def addDB(self, events): - dbFile, _filter = QtWidgets.QFileDialog.getOpenFileName( - caption="Select database file", + dbFiles, _filter = QtWidgets.QFileDialog.getOpenFileNames( + caption="Select database file(s)", dir=self.lastOpenDir, filter="Database (*.xlsx *.tsv);;Excel files (*.xlsx);;TSV files (*.tsv);;All files (*.*)", ) - dbFile = str(dbFile) - if len(dbFile) > 0: - self.lastOpenDir = str(dbFile).replace("\\", "/") - self.lastOpenDir = self.lastOpenDir[: self.lastOpenDir.rfind("/")] + for dbFile in dbFiles: + dbFile = str(dbFile) + if len(dbFile) > 0: + self.lastOpenDir = dbFile.replace("\\", "/") + self.lastOpenDir = self.lastOpenDir[: self.lastOpenDir.rfind("/")] - dbFile = dbFile.replace("\\", "/") - dbName = dbFile[dbFile.rfind("/") + 1 : dbFile.rfind(".")] + dbFile = dbFile.replace("\\", "/") + dbName = dbFile[dbFile.rfind("/") + 1 : dbFile.rfind(".")] - item = QtGui.QStandardItem("%s (Database)" % dbName) - item.setData(dbFile) - self.ui.dbList_listView.model().appendRow(item) + item = QtGui.QStandardItem("%s (Database)" % dbName) + item.setData(dbFile) + self.ui.dbList_listView.model().appendRow(item) def addMZVaultRepository(self, events): dbFile = QtWidgets.QFileDialog.getOpenFileName( diff --git a/src/annotateResultMatrix.py b/src/annotateResultMatrix.py index 48361ec..cd4b80c 100644 --- a/src/annotateResultMatrix.py +++ b/src/annotateResultMatrix.py @@ -203,21 +203,20 @@ def annotateWithDatabases( dbNames = [] # Import database files and collect database names - logging.info(f"Importing {len(dbFiles)} database file(s)") + logging.info(f"\n\n#########################################\nImporting {len(dbFiles)} database file(s)") for dbFile in dbFiles: + logging.info(f"\n-------------------------\nImporting database file: {dbFile}") dbName = dbFile[dbFile.rfind("/") + 1 : dbFile.rfind(".")] + dbNames.append(dbName) try: - imported, notImported = db.addEntriesFromFile(dbName, dbFile) - if notImported > 0: - logging.warning(f"Warning: {notImported} entries from database '{dbName}' were not imported successfully") - dbNames.append(dbName) - logging.info(f" Imported {imported} entries from database '{dbName}'") + db.addEntriesFromFile(dbName, dbFile) except IOError as e: - logging.error(f"Cannot open database file '{dbName}' at '{dbFile}': {e}") + logging.error(f" - Cannot process database file '{dbName}' at '{dbFile}': {e}") continue + logging.info(f"\nFinished importing databases. Total imported entries: MZ: {len(db.dbEntriesMZ)}, Neutral: {len(db.dbEntriesNeutral)}") # Optimize database for searching - logging.info("Optimizing database for searching") + logging.info("\nOptimizing database for searching") db.optimizeDB() # Add all necessary annotation columns BEFORE searching @@ -363,7 +362,7 @@ def searchForRow(self, row): if pwMaxSet is not None: pwMaxSet(total_rows) - logging.info(f"Searching database hits for {total_rows} metabolites") + logging.info(f"Searching database hits for {total_rows} metabolites, parameters are ppm: {ppm}, correctppmPosMode: {correctppmPosMode}, correctppmNegMode: {correctppmNegMode}, rtError: {rtError}, useRt: {useRt}, checkXnInHits: {checkXnInHits}, processedElement: {processedElement}") # Collect all hits for compound-focused sheet all_compound_hits = [] @@ -378,6 +377,8 @@ def searchForRow(self, row): # Search for database hits hits_per_db, hit_objects = searcher.searchForRow(row) + print(f"Row {row_idx + 1}/{total_rows}, mz {row.get('MZ')}, rt {row.get('RT')}, charge {row.get('Charge')}, polarity {row.get('Ionisation_Mode')}, Xn {row.get('Xn')}\n - Found hits in {len(hits_per_db)} databases") + # Update only if there are hits if hits_per_db: for dbName, hit_data in hits_per_db.items(): diff --git a/src/mePyGuis/guis/mainwindow.ui b/src/mePyGuis/guis/mainwindow.ui deleted file mode 100644 index 5c130f0..0000000 --- a/src/mePyGuis/guis/mainwindow.ui +++ /dev/null @@ -1,6389 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1954 - 1700 - - - - true - - - MetExtract - - - - :/MEIcon/resources/MEIcon.ico:/MEIcon/resources/MEIcon.ico - - - - - - QTabWidget::Rounded - - - - - 0 - 0 - - - - - 2 - - - 2 - - - 2 - - - 2 - - - - - - 0 - 0 - - - - - - - - - - - - - - true - - - - 0 - 0 - - - - - - - QTabWidget::West - - - QTabWidget::Rounded - - - 1 - - - - Input - - - - 3 - - - 0 - - - - - Qt::Vertical - - - QSizePolicy::Ignored - - - - 20 - 40 - - - - - - - - - 0 - 0 - - - - - - - - 9 - - - 9 - - - 9 - - - 9 - - - - - font: 10pt; - - - Experiment ID - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 16777215 - 1677 - - - - QAbstractItemView::SingleSelection - - - - - - - Qt::Horizontal - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - Save Groups - - - - - - - - 0 - 0 - - - - Load Groups - - - - - - - Qt::Vertical - - - - - - - - 0 - 0 - - - - New group - - - - :/new/archive-insert-3.png:/new/archive-insert-3.png - - - - - - - - 0 - 0 - - - - Delete group - - - - :/delete/archive-remove.png:/delete/archive-remove.png - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - Qt::Vertical - - - - - - - 0 - - - - - font: 10pt; - - - Comments - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - color: rgb(90, 90, 90); -font: 7pt; - - - Please describe the performed experiment - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 20 - - - - - - - - - 0 - 0 - - - - - - - - - - - 0 - 0 - - - - Positive - - - - - - - - 0 - 0 - - - - - - - - Negative - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 40 - - - - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - - 16777215 - 100 - - - - - - - - - - font: 10pt; - - - Measurements - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the sample groups in the experiment. Each group may have several measurement files and represents the different conditions in the experiment (e.g. different conditions and controls, different genotypes)</p><p>Double click on a group to edit it</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 200 - 0 - - - - - - - - - - font: 10pt; - - - Experiment - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 80 - - - - - - - - font: 30pt "Calibri"; - - - - LC-HRMS data - - - - - - - QFrame::Sunken - - - Qt::Vertical - - - - - - - QFrame::Sunken - - - Qt::Vertical - - - - - - - font: 30pt "Calibri"; - - - - Experiment description - - - - - - - font: 10pt; - - - Operator - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - QFrame::Sunken - - - Qt::Vertical - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - Qt::Horizontal - - - - - - - - - - 0 - 0 - - - - font: 10pt; - - - Scan event(s) - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 0 - 0 - - - - - 200 - 16777215 - - - - true - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please select the appropriate scan event(s). MetExtract is able to work with one positive and one negative scan events at a time. If only one ionisation mode should be used, select empty for the opposite mode.<br/><br/>Note: only those scan event common among all loaded measurement files are available. In case no common scan events are present, the calculation cannot be started.</p></body></html> - - - true - - - - - - - - - - - font: 10pt; - - - Define/Edit group - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - - - - false - - - Process - - - - - - - 0 - 0 - - - - - 3 - - - 3 - - - 3 - - - 3 - - - - - Qt::Horizontal - - - - 475 - 20 - - - - - - - - font: 30pt "Calibri"; - - - - <html><head/><body><p><span style=" font-size:16pt;">Run task(s)</span></p></body></html> - - - - - - - Keep one core unused - - - true - - - - - - - CPU cores - - - - - - - 1 - - - - - - - Start - - - - - - - - - - QFrame::Plain - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - QFrame::NoFrame - - - QFrame::Plain - - - true - - - - - 0 - 0 - 1889 - 1965 - - - - - 0 - 0 - - - - - - - Qt::LeftToRight - - - font: 30pt "Calibri"; - - - - 2nd step: Multiple files annotation - - - - - - - Qt::Vertical - - - - 0 - 0 - - - - - - - - If checked, MetExtract will search for features in the input files - - - Qt::LeftToRight - - - font: 30pt "Calibri"; - - - - 1st step: Individual files processing - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 50 - - - - - - - - Qt::LeftToRight - - - font: 30pt "Calibri"; - - - - 3rd step: Annotate metabolites - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 50 - - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 50 - - - - - - - - font: 30pt "Calibri"; - - - - 4th step: Generate MSMS target lists - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 0 - 0 - - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Please specify the settings for your experiment</p><p>The section &quot;Labeling&quot; provides options for describing the experiment's stable isotopic labeling.</p><p>The section &quot;MZ picking&quot; and &quot;MZ clustering&quot; describe the parameters of your HRMS device</p><p>The section &quot;Chromatographic separation&quot; describes your LC device and are parameters used for peak picking</p><p>The results of each measurement is automatically saved in tabular format to &lt;FileName&gt;.tsv. Additionally the results can be saved as &lt;FileName&gt;.pdf with graphical illustrations. </p><p>For some LC-HRMS devices it is also possible to save &lt;FileName&gt;.mzXML files. These files only include those mass peaks originating from the labeling process</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Visual config - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 200 - 40 - - - - - - - - - - Qt::Vertical - - - - - - - - - 9 - - - 9 - - - - - Save results - - - - 3 - - - 3 - - - 3 - - - 3 - - - 2 - - - - - New mzXML file - - - true - - - false - - - - - - M - - - true - - - - - - - M+1 - - - - - - - ... - - - - - - - M'-1 - - - - - - - M' - - - true - - - - - - - - - - PDF - - - true - - - false - - - - - - - TSV - - - true - - - false - - - - - - - FeatureML (basic) - - - true - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 3 - 0 - - - - - - - - - - - - Post processing - - - - - - - Qt::Horizontal - - - - - - - - - - - Correct C-Count - - - - - - - Isotopolog ratios - - - - - - 1 - - - - - - - Native - - - - - - - Labeled - - - - - - - -99 - - - 0 - - - 1 - - - -1 - - - - - - - Moiety - - - - - - - 1 - - - - - - - - - - Hetero isotopologue annotation - - - false - - - - - - - - - 1 - - - 3 - - - - - - - Found in scans - - - - - - - ± - - - % - - - 100.000000000000000 - - - 15.000000000000000 - - - - - - - Intensity deviation - - - - - - - Heteroatoms configuration - - - - - - - - - - Feature convolution - - - false - - - - - - Pearson correlation - - - - - - - - - - % - - - -100.000000000000000 - - - 100.000000000000000 - - - 5.000000000000000 - - - 85.000000000000000 - - - - - - - Relationship configuration - - - - - - - Number of connected features pairs - - - - - - - - - - % - - - 100.000000000000000 - - - 0.100000000000000 - - - 40.000000000000000 - - - - - - - Simplify in-source fragments - - - true - - - - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 20 - 0 - - - - - - - - - - - - - - - - - 0 - 0 - - - - Chromatographic separation - - - - - - - Qt::Horizontal - - - - - - - MZ clustering - - - - - - scans - - - - - - 1 - - - 9999 - - - 3 - - - - - - - Minimum spectra - - - - - - - Clustering window - - - - - - - - - - ppm - - - 1.000000000000000 - - - 8.000000000000000 - - - - - - - - - - - - XIC extraction - - - - - - Smoothing window - - - - - - - Identified M/Z Peaks are binned together using hierachical Clustering. This Parameter defines how far apart these M/Z Peaks may be - - - EIC width - - - - - - - - 0 - 0 - - - - - 130 - 0 - - - - - None - - - - - Triangle - - - - - Flat - - - - - Gaussian - - - - - Hanning - - - - - SavitzkyGolay - - - - - - - - ± - - - ppm - - - 5.000000000000000 - - - - - - - - - - Polynom - - - - - - - Window size - - - - - - - scans - - - ± - - - 1 - - - 99 - - - - - - - - - - Chromatographic separation - - - - - - - - - scans - - - 0 - - - 3.000000000000000 - - - - - - - Minimum scale - - - - - - - Maximum scale - - - - - - - - - - scans - - - 0 - - - 1000.000000000000000 - - - 19.000000000000000 - - - - - - - SNR threshold - - - - - - - - - - - - - 3 - - - - - - - - - - Peak matching - - - - - - scans - - - - - - 5 - - - - - - - Scale error - - - - - - - - - - % - - - -100.000000000000000 - - - 100.000000000000000 - - - 5.000000000000000 - - - 85.000000000000000 - - - - - - - Center error - - - - - - - Minimum corr - - - - - - - - - - 3 - - - - - - - EIC M' artificial shift - - - - - - - - - scans - - - - - - -100 - - - 100 - - - - - - - - 0 - 0 - - - - - - - - - - - - scans - - - -100 - - - 100 - - - - - - - - - - - - Required M:M' peak area ratio - - - true - - - false - - - - - - - - - 4 - - - 0.000100000000000 - - - - - - - - - - 999999.000000000000000 - - - 9999.000000000000000 - - - - - - - Minimum ratio - - - - - - - Maximum ratio - - - - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 20 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - Qt::Vertical - - - - - - - 6 - - - - - - - Labeling options - - - Labeling - - - - - - - Qt::Horizontal - - - - - - - - - Labeling - - - - - - - Isotope N - - - - - - - - - 0 - 0 - - - - true - - - - - - - - 0 - 0 - - - - Isotope N - - - 3 - - - - - - - - 0 - 0 - - - - Purity of the Base Growth Media - - - Isotopic enrichment - - - 3 - - - - - - - - 0 - 0 - - - - % - - - 2 - - - 100.000000000000000 - - - 0.100000000000000 - - - 98.930000000000007 - - - - - - - - 0 - 0 - - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - 3 - - - - - - - - - - - - Isotope L - - - - - - - - - 0 - 0 - - - - - - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - 3 - - - - - - - - 0 - 0 - - - - Isotope L - - - 3 - - - - - - - - 0 - 0 - - - - true - - - - - - - - 0 - 0 - - - - Purity of the Isotope Growth Media - - - Isotopic enrichment - - - 3 - - - - - - - - 0 - 0 - - - - % - - - 2 - - - 100.000000000000000 - - - 0.100000000000000 - - - 99.510000000000005 - - - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - Use Carbon-isotopepattern validation - - - false - - - - - - - Required M:M' signal ratio - - - true - - - false - - - - - - - - Minimum ratio - - - - - - - Maximum ratio - - - - - - - - - - 4 - - - 9999.989999999999782 - - - 0.100000000000000 - - - - - - - true - - - - - - 999999.989999999990687 - - - 0.100000000000000 - - - 9999.000000000000000 - - - - - - - - - - - - - - - 0 - 0 - - - - Click to specify used Xenobiotics or tracers for the metabolisation study - - - Tracer setup - - - - - - - Qt::Horizontal - - - - 0 - 0 - - - - - - - - Qt::Horizontal - - - - 0 - 0 - - - - - - - - No tracers configured - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 200 - 0 - - - - - - - - - - Qt::Vertical - - - - - - - - - - - - 0 - 0 - - - - MZ picking - - - - - - - - 0 - 0 - - - - Qt::Horizontal - - - - - - - - - - 0 - 0 - - - - Number of labeling (X) atoms to search for - - - - - - - - - 12-15, 30, 45 - - - - - - - - - - - 0 - 0 - - - - Scan range - - - - - - - - From - - - - - - - minutes - - - 9999.989999999999782 - - - 25.000000000000000 - - - - - - - To - - - - - - - - 0 - 11 - - - - - 16777215 - 16777215 - - - - minutes - - - 9999.989999999999782 - - - 3.000000000000000 - - - - - - - - - - - - Signal abundance - - - - - - true - - - - - - 0 - - - 1000000000.000000000000000 - - - 1000.000000000000000 - - - 1000.000000000000000 - - - - - - - Intensity Threshold for the pure Base M/Z Peak -This threshold is not used for mixed Base and Isotope Peaks or for -the pure Isotope Peak - - - Intensity threshold - - - - - - - Intensity cutoff - - - - - - - true - - - - - - - - - 100000000 - - - 1000 - - - - - - - - - - Charge - - - - - - Number of charges - - - 3 - - - - - - - - - - 1 - - - 3 - - - 2 - - - - - - - - - - Mass deviation - - - - - - Ppm Range in which the Isotope M/Z Peak and the mixed Base and Intensity Peaks can be found - - - Mass deviation - - - 3 - - - - - - - ± - - - ppm - - - 2.000000000000000 - - - - - - - - - - Isotopologs - - - - - - The observed Intensities differ from the theoretical Intensities for a certain M/Z. This parameter controls the deviation of the theoretical and observed Intensities for a mixed Base and Isotope M/Z Peak - - - Maximum ratio deviation native - - - - - - - ± - - - % - - - 200.000000000000000 - - - 1.000000000000000 - - - 25.000000000000000 - - - - - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The observed Intensities differ from the theoretical Intensities for a certain M/Z. This parameter controls the deviation of the theoretical and observed Intensities for a mixed Base and Isotope M/Z Peak</span></p></body></html> - - - Maximum ratio deviation labeled - - - - - - - ± - - - % - - - 200.000000000000000 - - - 1.000000000000000 - - - 25.000000000000000 - - - - - - - The number of M/Z Peaks that are required for a Base M/Z or an Isotope M/Z to be marked as labeled - - - Number of isotopologs native - - - 3 - - - - - - - Number of isotopologs labeled - - - 3 - - - - - - - = - - - 2 - - - 2 - - - - - - - Consider isotopolog abundance - - - - - - - - - - = - - - 1 - - - 2 - - - 2 - - - - - - - true - - - - - - 0 - - - 1000000000.000000000000000 - - - 1000.000000000000000 - - - 1000.000000000000000 - - - - - - - Isotopolog threshold - - - - - - - Scan index offset for labeled signal search. When set to 0, both native and labeled forms are searched in the same scan. A negative value n means the labeled form is searched n scans before the current native scan, a positive value means n scans after. - - - Labeled scan offset - - - 3 - - - - - - - Scan index offset for labeled signal search. 0 = same scan as native. Negative = labeled signal searched in earlier scans, positive = later scans. - - - = - - - -100 - - - 100 - - - 0 - - - - - - - - - - Qt::Vertical - - - - 20 - 0 - - - - - - - - - - - - Qt::Horizontal - - - - - - - - - - - - QFrame::Raised - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 1550 - 0 - - - - Qt::Horizontal - - - - - - - QFrame::Raised - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Qt::Vertical - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - - - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Detected metabolites will be used to generate one or severl lists of MSMS targets for targeted fragmentation experiments</p><p>Separate lists will be generated for the positive and negative ionization modes</p></body></html> - - - true - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - - Minimum required abundance - - - - - - - true - - - - - - 1 - - - 1000000000 - - - 10000 - - - 100000 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - MSMSM target file location - - - - - - - - 0 - 0 - - - - - 700 - 0 - - - - - - - - Select file - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - Retention time window for MSMS targets - - - - - - - ± - - - minutes - - - 1000.000000000000000 - - - 0.250000000000000 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - Maximum number of parallel MSMS targets - - - - - - - - - - 1 - - - 5 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - Number of MSMS replicates per sample - - - - - - - 1 - - - 2 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - Heuristic optimisation: Generation: - - - - - - - Number of offsprings per generation - - - - - - - 1 - - - 20 - - - - - - - Number of generations - - - - - - - 1 - - - 5000 - - - 500 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - 0 - - - - - 16777215 - 300 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Qt::Vertical - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - - - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Detected and bracketed results will be annotated with putative sum formulas (Seven Golden Rules Kind et al. 2007) and/or metabolite databases (in the form of TSV lists)</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::MinimumExpanding - - - - 20 - 0 - - - - - - - - - - - - Generate sum formulas - - - true - - - - - - Elements - - - - - - Minimum - - - - - - - - 0 - 0 - - - - CHNOS - - - - - - - Maximum - - - - - - - - 0 - 0 - - - - C80H300N10O20S10 - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 0 - 0 - - - - - - - - - - - Annotation databases - - - true - - - - - - Check retention time - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Add database - - - - - - - false - - - Add mzVault repository - - - - - - - Remove annotation source - - - - - - - Qt::Vertical - - - - - - - Generate database template - - - - - - - - - - 0 - 0 - - - - - 50 - 30 - - - - - 16777215 - 70 - - - - - - - - - - Maximum retention time deviation - - - - - - - ± - - - minutes - - - 0.150000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Minimum MSMS similarity score - - - - - - - - 80 - 0 - - - - - - - 1.000000000000000 - - - 0.700000000000000 - - - - - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 40 - 20 - - - - - - - - - - - - - - - Maximum allowed mass deviation - - - - - - - ± - - - ppm - - - 3.000000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Correct mass by - - - - - - - positive mode - - - - - - - ppm - - - -20.000000000000000 - - - 20.000000000000000 - - - 0.500000000000000 - - - - - - - negative mode - - - - - - - ppm - - - -20.000000000000000 - - - 20.000000000000000 - - - 0.500000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Use number of labeling atoms - - - - - - - - Exact - - - - - Don't use - - - - - Minimum - - - - - PlusMinus - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - - - - - 0 - 0 - - - - - 0 - 100 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Qt::Vertical - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 0 - 5 - - - - - - - - - 200 - 0 - - - - - 200 - 16777215 - - - - color: rgb(90, 90, 90); -font: 7pt; - - - <html><head/><body><p>Features found in several measurement files may be grouped together (optional: chromatographic alignment)<br/><br/>Not detected feature pairs (e.g. due to low intensity) from other measurement files may be integrated in a targeted fashion</p></body></html> - - - Qt::AlignJustify|Qt::AlignVCenter - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 0 - - - - - - - - - - - - - 0 - 0 - - - - Bracket results - - - true - - - - - - - - Maximum mz width - - - - - - - ± - - - ppm - - - 0.010000000000000 - - - 4.000000000000000 - - - - - - - - - - - Align chromatograms - - - true - - - - - - - Qt::Vertical - - - - - - - n-th polynom - - - - - - - 1 - - - 99 - - - 1 - - - - - - - - - - - Time window - - - - - - - ± - - - minutes - - - 3 - - - 0.010000000000000 - - - 0.100000000000000 - - - - - - - - - - - - - 0 - 0 - - - - Convolute results - - - true - - - - - - - - Maximum time window - - - - - - - ± - - - minutes - - - 0.050000000000000 - - - - - - - Minimum connections - - - - - - - - - - % - - - 100.000000000000000 - - - 40.000000000000000 - - - - - - - Minimum number of detections in files - - - - - - - - - - - - - 0 - - - 999 - - - 1 - - - - - - - Use SIL ratio - - - - - - - Use abundance similarity - - - true - - - - - - - Abundance similarity threshold - - - - - - - - - - % - - - 100.000000000000000 - - - 85.000000000000000 - - - - - - - - - - - - - 0 - 0 - - - - Integrate missing feature pairs - - - true - - - - - - - - Maximum time difference - - - - - - - ± - - - minutes - - - 2 - - - 0.100000000000000 - - - - - - - Intensity cutoff - - - - - - - true - - - - - - - - - 100000000 - - - 1000 - - - - - - - - - - - - Export peak areas - - - true - - - - - - - Export peak apex intensity - - - - - - - Export peak SNR - - - - - - - Qt::Horizontal - - - - - - - - - Save grouped results to - - - - - - - - 0 - 0 - - - - - 700 - 0 - - - - - - - - Select file - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - 0 - - - - QFrame::Raised - - - Qt::Horizontal - - - - - - - - - - - - false - - - Sample results - - - - 0 - - - 0 - - - 0 - - - 0 - - - 2 - - - - - - - - - - 255 - 255 - 255 - - - - - - - 240 - 240 - 240 - - - - - - - - - 255 - 255 - 255 - - - - - - - 240 - 240 - 240 - - - - - - - - - 240 - 240 - 240 - - - - - - - 240 - 240 - 240 - - - - - - - - QFrame::NoFrame - - - QFrame::Plain - - - true - - - - - 0 - 0 - 1201 - 1387 - - - - - - - Qt::Horizontal - - - false - - - false - - - - - - - Sort according to - - - - - - - - 0 - 0 - - - - - 400 - 0 - - - - QAbstractItemView::ExtendedSelection - - - true - - - 11 - - - 75 - - - 30 - - - - 1 - - - - - 2 - - - - - 3 - - - - - 4 - - - - - 5 - - - - - 6 - - - - - 7 - - - - - 8 - - - - - 9 - - - - - 10 - - - - - 11 - - - - - - - - - - - 0 - 0 - - - - Processed file - - - - - - - 30 - - - true - - - - - - - - - - - - 0 - 0 - - - - Filter - - - - - - - - 0 - 0 - - - - - - - - - - - - - 0 - 0 - - - - Result name - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - - 20 - 0 - - - - - 28 - 16777215 - - - - Set - - - - - - - - - - RT - - - - - M/Z - - - - - Intensity - - - - - Peaks correlation - - - - - - - - Open externally - - - - - - - - - - - 0 - - - - EICs and Mass spectra - - - - - - QFrame::NoFrame - - - 0 - - - 1 - - - Qt::Vertical - - - true - - - - - - - - - <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">EICs of native and labeled metabolite ion</span></p></body></html> - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - Show options - - - - - - - - 0 - 0 - - - - Hide options - - - - - - - - - - 0 - 0 - - - - - 700 - 350 - - - - - - - - Options - - - - - - Autozoom to chromatographic peaks - - - true - - - - - - - Show EIC of labeled ion form with negative intensities - - - true - - - - - - - Add balloon labels to the EIC plot - - - true - - - - - - - Color peak area - - - false - - - - - - - Shift the labeled EIC artificially - - - - - - - Normalise peak abundances to 1 - - - true - - - - - - - Normalise native and labeled peaks separately - - - - - - - Show EICs of isotopologs M+1 and M'-1 - - - - - - - Subtract the baseline of the EICs - - - false - - - - - - - Show smoothed signal - - - - - - - Crop EICs to chromatographic peaks - - - - - - - Show legend - - - true - - - - - - - Qt::Horizontal - - - - - - - Add labels to other features detected with the same m/z value - - - - - - - Set peak centers to 0 to improve their comparison - - - - - - - - - - - - - - - 0 - 0 - - - - - 700 - 350 - - - - - - - - QFrame::Raised - - - Qt::Horizontal - - - - - - - - - <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Mass spectra of native and labeled metabolite ion</span></p></body></html> - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Show options - - - - - - - - 0 - 0 - - - - Hide options - - - - - - - - - Options - - - - - - Labels - - - true - - - - - - - Isotopologues - - - true - - - - - - - M-1, M'+1 - - - true - - - - - - - - - Isotopolog deviation - - - - - - - ± - - - ppm - - - 1 - - - 5.000000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - - - - - Group results - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - - - - - QFrame::Raised - - - Qt::Horizontal - - - - - - - - - - - - - - - - - - - - false - - - Experiment results - - - - - - - - - - - 255 - 255 - 255 - - - - - - - 240 - 240 - 240 - - - - - - - - - 255 - 255 - 255 - - - - - - - 240 - 240 - 240 - - - - - - - - - 240 - 240 - 240 - - - - - - - 240 - 240 - 240 - - - - - - - - QFrame::NoFrame - - - QFrame::Plain - - - true - - - - - 0 - 0 - 1353 - 776 - - - - - - - Options - - - - - - - - EIC - - - - - - - ± - - - ppm - - - 1 - - - 1.000000000000000 - - - 5.000000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Peak width - - - - - - - ± - - - minutes - - - 0.500000000000000 - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Normalise separately - - - - - - - Normalise to labeled features - - - false - - - - - - - - - - - - - 0 - 0 - - - - Export all as PDF - - - - - - - Qt::Horizontal - - - QSizePolicy::Preferred - - - - 40 - 20 - - - - - - - - - - - - <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Visualization of native and labeled metabolite forms in all samples</span></p></body></html> - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Show options - - - - - - - - 0 - 0 - - - - Hide options - - - - - - - Show custom feature - - - - - - - - - - 0 - 0 - - - - - 500 - 0 - - - - QAbstractItemView::ExtendedSelection - - - 6 - - - 40 - - - - 1 - - - - - 2 - - - - - 3 - - - - - 4 - - - - - 5 - - - - - 6 - - - - - - - - 0 - - - - Separated peaks - - - - - - - 0 - 0 - - - - - 700 - 500 - - - - - - - - - - - - Raw XICs - - - - - - - 0 - 0 - - - - - - - - - - Separate according to - - - - - - - - Group - - - - - Sample - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Abundance profiles - - - - - - - - Visualization - - - - - - - - Boxplot - - - - - Line plot - - - - - - - - Scale - - - - - - - - Linear - - - - - Logarithmic - - - - - - - - Normalization - - - - - - - - None - - - - - Scale to max sample - - - - - Scale to max experimental group - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - 0 - 0 - - - - - 700 - 500 - - - - - - - - - - - - - - - - - - - - - 0 - 0 - - - - color:grey; - - - TextLabel - - - Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing - - - - - - - - - 0 - 0 - 1954 - 21 - - - - - Help - - - - - - - - File - - - - Load Settings - - - - - - - - - - - - Tools - - - - - - - - - - - - - - - - About - - - - - Exit - - - - - Load from file - - - - - Save Settings - - - - - Help - - - - - Isotopic enrichment - - - - - Set working directory - - - - - Open temporary directory (logfile and caches) - - - - - Show overview of results - - - - - exExperimentName_LineEdit - exOperator_LineEdit - exExperimentID_LineEdit - exComments_TextEdit - groupsList - addGroup - positiveScanEvent - negativeScanEvent - isotopeAText - isotopicAbundanceA - isotopeBText - isotopicAbundanceB - useCValidation - minRatio - maxRatio - setupTracers - xCountSearch - scanStartTime - scanEndTime - clustPPM - minSpectraCount - wavelet_EICppm - eicSmoothingWindow - eicSmoothingWindowSize - smoothingPolynom_spinner - wavelet_minScale - wavelet_maxScale - wavelet_SNRThreshold - peak_centerError - peak_scaleError - minPeakCorr - doubleSpinBox_minPeakRatio - doubleSpinBox_maxPeakRatio - correctcCount - calcIsoRatioNative_spinBox - calcIsoRatioLabelled_spinBox - calcIsoRatioMoiety_spinBox - hAIntensityError - hAMinScans - defineHeteroAtoms - minCorrelation - minCorrelationConnections - relationshipConfig - saveCSV - savePDF - saveMZXML - wm_ia - wm_iap - wm_imb - wm_ib - groupResults - groupPpm - groupingRT - alignChromatograms - polynomValue - minConnectionRate - metaboliteClusterMinConnections - useAbundanceSimilarityForConvolution - abundanceSimilarityThreshold - integratedMissedPeaks - integrationMaxTimeDifference - reintegrateIntensityCutoff - groupsSelectFile - workingCore - cpuCores - startIdentification - dataFilter - tabWidget_2 - scrollArea - resultsExperiment_TreeWidget - tabWidget_3 - setChromPeakName - visualConfig - scrollArea_2 - res_ExtractedData - saveGroups - loadGroups - removeGroup - scrollArea_3 - groupsSave - chromPeakName - processedFilesComboBox - - - - - - - pushButton_showOptions1 - clicked() - groupBox_options1 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions1 - clicked() - pushButton_hideOptions1 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions1 - clicked() - pushButton_showOptions1 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions1 - clicked() - groupBox_options1 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions1 - clicked() - pushButton_showOptions1 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions1 - clicked() - pushButton_hideOptions1 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions2 - clicked() - groupBox_options2 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions2 - clicked() - pushButton_hideOptions2 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions2 - clicked() - pushButton_showOptions2 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions2 - clicked() - groupBox_options2 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions2 - clicked() - pushButton_showOptions2 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions2 - clicked() - pushButton_hideOptions2 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions3 - clicked() - groupBox_options3 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions3 - clicked() - pushButton_hideOptions3 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_showOptions3 - clicked() - pushButton_showOptions3 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions3 - clicked() - groupBox_options3 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions3 - clicked() - pushButton_showOptions3 - show() - - - 20 - 20 - - - 20 - 20 - - - - - pushButton_hideOptions3 - clicked() - pushButton_hideOptions3 - hide() - - - 20 - 20 - - - 20 - 20 - - - - - diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 1633db2..4b7c9aa 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -1656,7 +1656,6 @@ def setupUi(self, MainWindow): self.gridLayout_51.setObjectName(_fromUtf8("gridLayout_51")) self.checkRTInHits_checkBox = QtWidgets.QCheckBox(self.searchDB_checkBox) self.checkRTInHits_checkBox.setObjectName(_fromUtf8("checkRTInHits_checkBox")) - self.gridLayout_51.addWidget(self.checkRTInHits_checkBox, 1, 0, 1, 1) self.horizontalLayout_23 = QtWidgets.QHBoxLayout() self.horizontalLayout_23.setObjectName(_fromUtf8("horizontalLayout_23")) spacerItem48 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) @@ -1666,6 +1665,7 @@ def setupUi(self, MainWindow): self.horizontalLayout_23.addWidget(self.addDB_pushButton) self.addmzVaultRep_pushButton = QtWidgets.QPushButton(self.searchDB_checkBox) self.addmzVaultRep_pushButton.setEnabled(False) + self.addmzVaultRep_pushButton.setVisible(False) self.addmzVaultRep_pushButton.setObjectName(_fromUtf8("addmzVaultRep_pushButton")) self.horizontalLayout_23.addWidget(self.addmzVaultRep_pushButton) self.removeDB_pushButton = QtWidgets.QPushButton(self.searchDB_checkBox) @@ -1681,7 +1681,7 @@ def setupUi(self, MainWindow): self.horizontalLayout_23.addWidget(self.generateDBTemplate_pushButton) self.gridLayout_51.addLayout(self.horizontalLayout_23, 3, 0, 1, 7) self.dbList_listView = QtWidgets.QListView(self.searchDB_checkBox) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.dbList_listView.sizePolicy().hasHeightForWidth()) @@ -1690,28 +1690,31 @@ def setupUi(self, MainWindow): self.dbList_listView.setMaximumSize(QtCore.QSize(16777215, 70)) self.dbList_listView.setObjectName(_fromUtf8("dbList_listView")) self.gridLayout_51.addWidget(self.dbList_listView, 0, 0, 1, 7) - self.horizontalLayout_11 = QtWidgets.QHBoxLayout() - self.horizontalLayout_11.setObjectName(_fromUtf8("horizontalLayout_11")) + self.horizontalLayout_checkRT = QtWidgets.QHBoxLayout() + self.horizontalLayout_checkRT.setObjectName(_fromUtf8("horizontalLayout_checkRT")) + self.horizontalLayout_checkRT.addWidget(self.checkRTInHits_checkBox) self.label_79 = QtWidgets.QLabel(self.searchDB_checkBox) self.label_79.setObjectName(_fromUtf8("label_79")) - self.horizontalLayout_11.addWidget(self.label_79) + self.horizontalLayout_checkRT.addWidget(self.label_79) self.maxRTErrorInHits_spinnerBox = QtWidgets.QDoubleSpinBox(self.searchDB_checkBox) self.maxRTErrorInHits_spinnerBox.setProperty("value", 0.15) self.maxRTErrorInHits_spinnerBox.setObjectName(_fromUtf8("maxRTErrorInHits_spinnerBox")) - self.horizontalLayout_11.addWidget(self.maxRTErrorInHits_spinnerBox) + self.horizontalLayout_checkRT.addWidget(self.maxRTErrorInHits_spinnerBox) spacerItem49 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout_11.addItem(spacerItem49) - self.gridLayout_51.addLayout(self.horizontalLayout_11, 1, 1, 1, 1) + self.horizontalLayout_checkRT.addItem(spacerItem49) + self.gridLayout_51.addLayout(self.horizontalLayout_checkRT, 1, 0, 1, 7) self.horizontalLayout_20 = QtWidgets.QHBoxLayout() self.horizontalLayout_20.setObjectName(_fromUtf8("horizontalLayout_20")) self.label_80 = QtWidgets.QLabel(self.searchDB_checkBox) self.label_80.setObjectName(_fromUtf8("label_80")) + self.label_80.setVisible(False) self.horizontalLayout_20.addWidget(self.label_80) self.minMSMSScore_doubleSpinBox = QtWidgets.QDoubleSpinBox(self.searchDB_checkBox) self.minMSMSScore_doubleSpinBox.setMinimumSize(QtCore.QSize(80, 0)) self.minMSMSScore_doubleSpinBox.setMaximum(1.0) self.minMSMSScore_doubleSpinBox.setProperty("value", 0.7) self.minMSMSScore_doubleSpinBox.setObjectName(_fromUtf8("minMSMSScore_doubleSpinBox")) + self.minMSMSScore_doubleSpinBox.setVisible(False) self.horizontalLayout_20.addWidget(self.minMSMSScore_doubleSpinBox) spacerItem50 = QtWidgets.QSpacerItem( 40, @@ -3295,7 +3298,7 @@ def retranslateUi(self, MainWindow): self.label_98.setText(_translate("MainWindow", "positive mode", None)) self.annotation_correctMassByPPMposMode.setSuffix(_translate("MainWindow", " ppm", None)) self.label_99.setText(_translate("MainWindow", "negative mode", None)) - self.annotation_correctMassByPPMnegMode.setSuffix(_translate("MainWindow", "ppm", None)) + self.annotation_correctMassByPPMnegMode.setSuffix(_translate("MainWindow", " ppm", None)) self.label_76.setText(_translate("MainWindow", "Use number of labeling atoms", None)) self.sumFormulasUseExactXn_ComboBox.setItemText(0, _translate("MainWindow", "Exact", None)) self.sumFormulasUseExactXn_ComboBox.setItemText(1, _translate("MainWindow", "Don't use", None)) diff --git a/src/reIntegration.py b/src/reIntegration.py index ca7b03b..0884207 100644 --- a/src/reIntegration.py +++ b/src/reIntegration.py @@ -18,6 +18,7 @@ from __future__ import absolute_import, division, print_function import gc import logging +import os import time import traceback from multiprocessing import Manager, Pool @@ -279,7 +280,7 @@ def processFile(self, params): List of re-integration results """ startProc = time.time() - logging.info(f" Reintegration started for file {self.forFile}") + logging.info(f" Reintegration started for file {os.path.basename(self.forFile)}") if self.queue is not None: self.queue.put(Bunch(pid=self.pID, mes="start")) @@ -348,10 +349,10 @@ def processFile(self, params): self.chromatogram.freeMe() gc.collect() - logging.info(f" Reintegration finished for file {self.forFile} ({(time.time() - startProc) / 60.0:.1f} minutes)") + logging.info(f" Reintegration finished for file {os.path.basename(self.forFile)} ({(time.time() - startProc) / 60.0:.1f} minutes)") except Exception as e: - logging.error(f"Error during reintegration of {self.forFile}: {e}") + logging.error(f"Error during reintegration of {os.path.basename(self.forFile)}: {e}") traceback.print_exc() finally: diff --git a/src/resultsPostProcessing/searchDatabases.py b/src/resultsPostProcessing/searchDatabases.py index 60afee0..1e38fca 100644 --- a/src/resultsPostProcessing/searchDatabases.py +++ b/src/resultsPostProcessing/searchDatabases.py @@ -1,3 +1,4 @@ +import os import sys import csv from copy import deepcopy @@ -5,6 +6,7 @@ from ..formulaTools import formulaTools import logging from .. import LoggingSetup +import polars as pl sys.path.append("C:/development/PyMetExtract") @@ -89,10 +91,26 @@ def addEntriesFromFile(self, dbName, dbFile, callBackCheckFunction=None): fT = formulaTools() + # check if file exists + + if not os.path.exists(dbFile): + logging.error("DB import error: File not found %s" % (dbFile)) + raise Exception("DB import error: File not found %s" % (dbFile)) + if dbFile.lower().endswith(".xlsx"): - import polars as pl + df = None + try: + df = pl.read_excel(dbFile, sheet_name="Template") + except Exception: + try: + df = pl.read_excel(dbFile, sheet_name="Sheet 1") + except Exception: + try: + df = pl.read_excel(dbFile) + except Exception: + logging.error("DB import error: Could not read Excel file %s; tried sheets 'Template', 'Sheet 1' and default sheet" % (dbFile)) + raise Exception("DB import error: Could not read Excel file %s; tried sheets 'Template', 'Sheet 1' and default sheet" % (dbFile)) - df = pl.read_excel(dbFile, sheet_name="Template") # Build a list-of-lists interface compatible with the TSV path header_row = list(df.columns) data_rows = [[str(v) if v is not None else "" for v in row] for row in df.iter_rows()] @@ -123,7 +141,11 @@ def _iter_rows(): num = row[headers["Num"]].strip().replace('"', "DOURBLEPRIME").replace("'", "PRIME").replace("\t", "TAB").replace("\n", "RETURN").replace("\r", "CarrRETURN").replace("#", "HASH") name = row[headers["Name"]].strip().replace('"', "DOURBLEPRIME").replace("'", "PRIME").replace("\t", "TAB").replace("\n", "RETURN").replace("\r", "CarrRETURN").replace("#", "HASH") sumFormula = row[headers["SumFormula"]].strip().replace('"', "DOURBLEPRIME").replace("'", "PRIME").replace("\t", "TAB").replace("\n", "RETURN").replace("\r", "CarrRETURN").replace("#", "HASH") - rt_min = float(row[headers["Rt_min"]]) if row[headers["Rt_min"]] != "" else None + rt_min = None + try: + rt_min = float(row[headers["Rt_min"]]) if row[headers["Rt_min"]] != "" else None + except Exception: + logging.error(" - DB (%s) import error (row %d): The Rt_min value '%s' of the entry %s '%s' could not be parsed as a float, not using RT for this compound" % (dbName, rowi, row[headers["Rt_min"]], num, name)) mz = float(row[headers["MZ"]]) if row[headers["MZ"]] != "" else None polarity = row[headers["IonisationMode"]].strip().replace('"', "DOURBLEPRIME").replace("'", "PRIME").replace("\t", "TAB").replace("\n", "RETURN").replace("\r", "CarrRETURN").replace("#", "HASH") additionalInfo = {} @@ -154,7 +176,7 @@ def _iter_rows(): entry_polarity = "+" if formula_charge > 0 else "-" is_charged_formula = True except Exception: - logging.error("DB import error (%s, row: %d): The sumformula (%s) of the entry %s '%s' could not be parsed" % (dbName, rowi, sumFormula, num, name)) + logging.error(" - DB (%s) import error (row %d): The sumformula (%s) of the entry %s '%s' could not be parsed" % (dbName, rowi, sumFormula, num, name)) notImported += 1 dbEntry = DBEntry( @@ -184,19 +206,18 @@ def _iter_rows(): imported += 1 except Exception as ex: - logging.error("DB import error: Could not import row %d (%s)" % (rowi, ex.message)) + logging.error(" - DB (%s) import error (row %d): %s" % (dbName, rowi, ex)) notImported += 1 logging.info( - "Imported DB %s with %d entries (Current number of entries: %d)" + " - Imported DB %s with %d entries" % ( dbName, len(self.dbEntriesMZ) + len(self.dbEntriesNeutral) - curEntriesCount, - len(self.dbEntriesMZ) + len(self.dbEntriesNeutral), ) ) if notImported > 0: - logging.error("Not imported %d entries (see above errors)" % (notImported)) + logging.error("Warning: Not imported %d entries (see above errors)" % (notImported)) return imported, notImported def optimizeDB(self): @@ -205,55 +226,35 @@ def optimizeDB(self): def _findGeneric(self, list, getValue, valueLeft, valueRight): if len(list) == 0: - return (-1, -1) - - min = 0 - max = len(list) - - while min < max and (max - min) > 1: - cur = int(ceil((max + min) / 2.0)) - - if valueLeft <= getValue(list[cur]) <= valueRight: - leftBound = cur - while leftBound > 0 and getValue(list[leftBound - 1]) >= valueLeft: - leftBound -= 1 + return [] - rightBound = cur - while (rightBound + 1) < len(list) and getValue(list[rightBound + 1]) <= valueRight: - rightBound += 1 + # implement binary search to find a value in the sorted list between valueLeft and valueRight + left = 0 + right = len(list) - 1 - return leftBound, rightBound + while left <= right: + middle = (left + right) // 2 + middleValue = getValue(list[middle]) - if getValue(list[cur]) > valueRight: - max = cur + if middleValue < valueLeft: + left = middle + 1 + elif middleValue > valueRight: + right = middle - 1 else: - min = cur - - cur = min - if valueLeft <= getValue(list[cur]) <= valueRight: - leftBound = cur - while leftBound > 0 and getValue(list[leftBound - 1]) >= valueLeft: - leftBound -= 1 - - rightBound = cur - while (rightBound + 1) < len(list) and getValue(list[rightBound + 1]) <= valueRight: - rightBound += 1 - - return leftBound, rightBound - - cur = len(list) - 1 - if valueLeft <= getValue(list[cur]) <= valueRight: - leftBound = cur - while leftBound > 0 and getValue(list[leftBound - 1]) >= valueLeft: - leftBound -= 1 + # find the leftmost and rightmost index with values between valueLeft and valueRight + leftIndex = middle + while leftIndex >= 0 and valueLeft <= getValue(list[leftIndex]) <= valueRight: + leftIndex -= 1 + leftIndex += 1 - rightBound = cur - while (rightBound + 1) < len(list) and getValue(list[rightBound + 1]) <= valueRight: - rightBound += 1 + rightIndex = middle + while rightIndex < len(list) and valueLeft <= getValue(list[rightIndex]) <= valueRight: + rightIndex += 1 + rightIndex -= 1 - return leftBound, rightBound + return list[leftIndex : rightIndex + 1] - return -1, -1 + return [] def searchDBForMZ( self, @@ -290,15 +291,14 @@ def searchDBForMZ( if polarity == adduct[2] and charges == adduct[3]: mass = (mz - adduct[1]) * adduct[3] / adduct[4] - ph = self._findGeneric( + entries = self._findGeneric( self.dbEntriesNeutral, lambda x: x.mass, mass - mass * ppm / 1000000.0, mass + mass * ppm / 1000000.0, ) - if ph[0] != -1: - for entryi in range(ph[0], ph[1] + 1): - entry = self.dbEntriesNeutral[entryi] + if entries: + for entry in entries: if rt_min is None or entry.rt_min is None or (abs(rt_min - entry.rt_min) <= rt_error): elems = None if entry.sumFormula != "": @@ -319,16 +319,14 @@ def searchDBForMZ( ## search for charged DB entries by the provided mz value - ph = self._findGeneric( + entries = self._findGeneric( self.dbEntriesMZ, lambda x: x.mz, mz - mz * ppm / 1000000.0, mz + mz * ppm / 1000000.0, ) - if ph[0] != -1: - for entryi in range(ph[0], ph[1] + 1): - entry = self.dbEntriesMZ[entryi] - print(entry) + if entries: + for entry in entries: if entry.polarity == polarity and (rt_min is None or entry.rt_min is None or (abs(rt_min - entry.rt_min) <= rt_error)): elems = None if entry.sumFormula != "": @@ -381,15 +379,14 @@ def searchDBForMass( if charges == adduct[3]: mz = mass * adduct[4] / adduct[3] + adduct[1] - ph = self._findGeneric( + entries = self._findGeneric( self.dbEntriesMZ, lambda x: x.mz, mz - mz * ppm / 1000000.0, mz + mz * ppm / 1000000.0, ) - if ph[0] != -1: - for entryi in range(ph[0], ph[1] + 1): - entry = self.dbEntriesMZ[entryi] + if entries: + for entry in entries: if entry.polarity == adduct[2] and (rt_min is None or entry.rt_min is None or (abs(rt_min - entry.rt_min) <= rt_error)): elems = None if entry.sumFormula != "": @@ -409,15 +406,14 @@ def searchDBForMass( possibleHits.append(entry) ## search for non-charged DB entries by the provided mass - ph = self._findGeneric( + entries = self._findGeneric( self.dbEntriesNeutral, lambda x: x.mass, mass - mass * ppm / 1000000.0, mass + mass * ppm / 1000000.0, ) - if ph[0] != -1: - for entryi in range(ph[0], ph[1] + 1): - entry = self.dbEntriesNeutral[entryi] + if entries: + for entry in entries: if rt_min is None or entry.rt_min is None or (abs(rt_min - entry.rt_min) <= rt_error): elems = None if entry.sumFormula != "": @@ -439,15 +435,14 @@ def searchDBForMass( ## search for charged formula DB entries by directly matching the feature m/z ## (only when polarity matches; these entries were imported from formulas with explicit charge) if mz is not None: - ph = self._findGeneric( + entries = self._findGeneric( self.dbEntriesMZ, lambda x: x.mz, mz - mz * ppm / 1000000.0, mz + mz * ppm / 1000000.0, ) - if ph[0] != -1: - for entryi in range(ph[0], ph[1] + 1): - entry = self.dbEntriesMZ[entryi] + if entries: + for entry in entries: if entry.polarity == polarity and (rt_min is None or entry.rt_min is None or (abs(rt_min - entry.rt_min) <= rt_error)): elems = None if entry.sumFormula != "": From 192f304049623d725a75e9388814026004419781 Mon Sep 17 00:00:00 2001 From: chrboku Date: Fri, 15 May 2026 14:36:19 +0200 Subject: [PATCH 20/27] added test import for databases --- generateGUIS.bat | 16 - src/MExtract.py | 86 +- src/annotateResultMatrix.py | 49 +- src/mePyGuis/guis/mainwindow.ui | 6390 ++++++++++++++++++ src/mePyGuis/mainWindow.py | 13 + src/resultsPostProcessing/searchDatabases.py | 43 +- 6 files changed, 6564 insertions(+), 33 deletions(-) delete mode 100644 generateGUIS.bat create mode 100644 src/mePyGuis/guis/mainwindow.ui diff --git a/generateGUIS.bat b/generateGUIS.bat deleted file mode 100644 index 0f9dff6..0000000 --- a/generateGUIS.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\mainwindow.ui -o .\mePyGuis\mainWindow.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\TracerEditor.ui -o .\mePyGuis\TracerEditor.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\adductsEditor.ui -o .\mePyGuis\adductsEditor.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\groupEditor.ui -o .\mePyGuis\groupEditor.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\heteroAtomEditor.ui -o .\mePyGuis\heteroAtomEditor.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\TSVLoaderEditor.ui -o .\mePyGuis\TSVLoaderEditor.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\ModuleSelectionWindow.ui -o .\mePyGuis\ModuleSelectionWindow.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\calcIsotopeEnrichmentDialog.ui -o .\mePyGuis\calcIsotopeEnrichmentDialog.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\FE_mainWindow.ui -o .\mePyGuis\FE_mainWindow.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\FTICRwindow.ui -o .\mePyGuis\FTICRWindow.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyuic4.bat -x .\mePyGuis\guis\combineResultsDialog.ui -o .\mePyGuis\combineResultsDialog.py -CALL c:\Python27\Lib\site-packages\PyQt4\pyrcc4 -o resources_rc.py resources.qrc -echo "Guis created.." - -::CALL c:\Python26\Lib\site-packages\PyQt4\pyuic4.bat -x guis\SettingsWizard.ui -o .\guis\pys\SettingsWizard.py \ No newline at end of file diff --git a/src/MExtract.py b/src/MExtract.py index 954acc3..89f4aa0 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -429,6 +429,20 @@ def paint(self, painter, option, index): painter.restore() +class _DBTestWorker(QtCore.QThread): + """Background worker that test-imports database files without blocking the UI.""" + + finished = QtCore.Signal(list) + + def __init__(self, dbFiles, parent=None): + super().__init__(parent) + self.dbFiles = dbFiles + + def run(self): + results = annotateResultMatrix.testDatabaseImports(self.dbFiles) + self.finished.emit(results) + + class mainWindow(QtWidgets.QMainWindow, Ui_MainWindow): # def forceUpdateFL(self): @@ -4635,6 +4649,7 @@ def runProcess(self, dontSave=False, askStarting=True): useExactXn = "PlusMinus_%d" % (self.ui.sumFormulasPlusMinus_spinBox.value()) try: + db_info_messages = [] addedColumns = annotateResultMatrix.annotateWithDatabases( file=excel_file, sheet_name=annotation_input_sheet, @@ -4650,9 +4665,16 @@ def runProcess(self, dontSave=False, askStarting=True): processedElement=getElementOfIsotope(str(self.ui.isotopeAText.text())), pwMaxSet=pw.getCallingFunction()("max"), pwValSet=pw.getCallingFunction()("value"), + db_info_messages=db_info_messages, ) annotationColumns.extend(addedColumns) + # Write DB_info log sheet + if db_info_messages: + from .utils import add_sheet_to_excel as _add_sheet_db_info + + _add_sheet_db_info(excel_file, pl.DataFrame({"text": db_info_messages}), "DB_info", overwrite=True) + if False: logging.info( "## Database search: checkXn %s, ppm: %.5f, correctppm: pos.mode: %.5f / neg.mode: %.5f, Adducts: %s" @@ -11738,6 +11760,64 @@ def generateDBTemplate(self, events): QtWidgets.QMessageBox.Ok, ) + def testDBs(self, events=None): + dbFiles = [] + for entryInd in range(self.ui.dbList_listView.model().rowCount()): + dbFile = str(self.ui.dbList_listView.model().item(entryInd, 0).data()) + dbFiles.append(dbFile) + + if not dbFiles: + QtWidgets.QMessageBox.information(self, "MetExtract", "No database files added.", QtWidgets.QMessageBox.Ok) + return + + # Show indeterminate progress dialog while importing + progress = QtWidgets.QProgressDialog("Importing database files, please wait…", None, 0, 0, self) + progress.setWindowTitle("Test DBs") + progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + progress.setValue(0) + progress.show() + QtWidgets.QApplication.processEvents() + + worker = _DBTestWorker(dbFiles, parent=self) + + def _on_finished(results): + progress.close() + self._showDBTestResults(results) + + worker.finished.connect(_on_finished) + worker.start() + + def _showDBTestResults(self, results): + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("Database Import Test Results") + dialog.resize(750, 450) + layout = QtWidgets.QVBoxLayout(dialog) + + tree = QtWidgets.QTreeWidget(dialog) + tree.setHeaderLabels(["Database / Message"]) + tree.setColumnCount(1) + tree.header().setStretchLastSection(True) + + for result in results: + has_errors = result["not_imported"] > 0 or len(result["errors"]) > 0 + status = "Issues found" if has_errors else "OK" + top_item = QtWidgets.QTreeWidgetItem( + tree, + [f"{result['db_name']} — Imported: {result['imported']}, Errors: {result['not_imported']} [{status}]"], + ) + if not has_errors: + QtWidgets.QTreeWidgetItem(top_item, [f"Successfully imported {result['imported']} entries"]) + for err in result["errors"]: + QtWidgets.QTreeWidgetItem(top_item, [err]) + top_item.setExpanded(has_errors) + + layout.addWidget(tree) + btn_close = QtWidgets.QPushButton("Close") + btn_close.clicked.connect(dialog.accept) + layout.addWidget(btn_close) + dialog.exec_() + # initialise main interface, triggers and command line parameters def __init__(self, module="TracExtract", parent=None, silent=False, disableR=False): super(Ui_MainWindow, self).__init__() @@ -12242,6 +12322,8 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal self.ui.addmzVaultRep_pushButton.clicked.connect(self.addMZVaultRepository) self.ui.removeDB_pushButton.clicked.connect(self.removeDB) self.ui.generateDBTemplate_pushButton.clicked.connect(self.generateDBTemplate) + self.ui.testDBs_pushButton.clicked.connect(self.testDBs) + self.ui.actionDownloadDBTemplate.triggered.connect(self.generateDBTemplate) # setup result plots # Setup first plot @@ -12653,7 +12735,9 @@ def main(): + "
" + f"If importing mzML files results in the error
of missing files, please find the correct version at
{OBO_DOWNLOAD_URL}.
" + "Please download the corresponding obo-file and
save it to the folder in the error message.
" - + "You can also open this page via the menu
('Tools'->'Download OBO files').", + + "You can also open this page via the menu
('Tools'->'Download OBO files').
" + + "
" + + "To generate a template for a database, select
'Download Database Template' from the 'Tools' menu.", QtWidgets.QMessageBox.Ok, ) diff --git a/src/annotateResultMatrix.py b/src/annotateResultMatrix.py index cd4b80c..6bd286f 100644 --- a/src/annotateResultMatrix.py +++ b/src/annotateResultMatrix.py @@ -153,6 +153,7 @@ def annotateWithDatabases( processedElement, pwMaxSet=None, pwValSet=None, + db_info_messages=None, ): """ Annotate metabolites by searching in databases using PolarsDB. @@ -180,6 +181,8 @@ def annotateWithDatabases( processedElement: Element to check in formulas (e.g., "C") pwMaxSet: Progress callback for max value pwValSet: Progress callback for current value + db_info_messages: Optional list; if provided, import summary messages are appended to it + (used to write the DB_info log sheet) Returns: List of annotation column names added @@ -208,10 +211,21 @@ def annotateWithDatabases( logging.info(f"\n-------------------------\nImporting database file: {dbFile}") dbName = dbFile[dbFile.rfind("/") + 1 : dbFile.rfind(".")] dbNames.append(dbName) + errors = [] try: - db.addEntriesFromFile(dbName, dbFile) + imported, not_imported = db.addEntriesFromFile(dbName, dbFile, error_collector=errors) + if db_info_messages is not None: + db_info_messages.append(f"Database: {dbName} (file: {dbFile})") + db_info_messages.append(f" Imported: {imported} entries successfully") + if not_imported > 0: + db_info_messages.append(f" Not imported: {not_imported} entries due to errors") + for err in errors: + db_info_messages.append(f" {err}") except IOError as e: logging.error(f" - Cannot process database file '{dbName}' at '{dbFile}': {e}") + if db_info_messages is not None: + db_info_messages.append(f"Database: {dbName} (file: {dbFile})") + db_info_messages.append(f" Fatal error: {e}") continue logging.info(f"\nFinished importing databases. Total imported entries: MZ: {len(db.dbEntriesMZ)}, Neutral: {len(db.dbEntriesNeutral)}") @@ -484,6 +498,39 @@ def searchForRow(self, row): return annotationColumns +def testDatabaseImports(dbFiles): + """ + Test-import database files and return per-db import results without writing to any output file. + + Args: + dbFiles: List of database file paths + + Returns: + List of dicts: [{'db_name': str, 'db_file': str, 'imported': int, 'not_imported': int, 'errors': list[str]}, ...] + """ + results = [] + for dbFile in dbFiles: + dbName = dbFile[dbFile.rfind("/") + 1 : dbFile.rfind(".")] + errors = [] + db = searchDatabases.DBSearch() + try: + imported, not_imported = db.addEntriesFromFile(dbName, dbFile, error_collector=errors) + except Exception as e: + errors.insert(0, f"Fatal error: {e}") + imported = 0 + not_imported = 0 + results.append( + { + "db_name": dbName, + "db_file": dbFile, + "imported": imported, + "not_imported": not_imported, + "errors": errors, + } + ) + return results + + def annotateWithSumFormulas( file, sheet_name, diff --git a/src/mePyGuis/guis/mainwindow.ui b/src/mePyGuis/guis/mainwindow.ui new file mode 100644 index 0000000..489535e --- /dev/null +++ b/src/mePyGuis/guis/mainwindow.ui @@ -0,0 +1,6390 @@ + + + MainWindow + + + + 0 + 0 + 1954 + 1700 + + + + true + + + MetExtract + + + + :/MEIcon/resources/MEIcon.ico:/MEIcon/resources/MEIcon.ico + + + + + + QTabWidget::Rounded + + + + + 0 + 0 + + + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + 0 + 0 + + + + + + + + + + + + + + true + + + + 0 + 0 + + + + + + + QTabWidget::West + + + QTabWidget::Rounded + + + 1 + + + + Input + + + + 3 + + + 0 + + + + + Qt::Vertical + + + QSizePolicy::Ignored + + + + 20 + 40 + + + + + + + + + 0 + 0 + + + + + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + font: 10pt; + + + Experiment ID + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 16777215 + 1677 + + + + QAbstractItemView::SingleSelection + + + + + + + Qt::Horizontal + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Preferred + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Save Groups + + + + + + + + 0 + 0 + + + + Load Groups + + + + + + + Qt::Vertical + + + + + + + + 0 + 0 + + + + New group + + + + :/new/archive-insert-3.png:/new/archive-insert-3.png + + + + + + + + 0 + 0 + + + + Delete group + + + + :/delete/archive-remove.png:/delete/archive-remove.png + + + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + + + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 20 + + + + + + + + Qt::Vertical + + + + + + + 0 + + + + + font: 10pt; + + + Comments + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + color: rgb(90, 90, 90); +font: 7pt; + + + Please describe the performed experiment + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 0 + 0 + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 0 + + + + + + + + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 20 + + + + + + + + + 0 + 0 + + + + + + + + + + + 0 + 0 + + + + Positive + + + + + + + + 0 + 0 + + + + + + + + Negative + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 40 + + + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 16777215 + 100 + + + + + + + + + + font: 10pt; + + + Measurements + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + + 0 + 0 + + + + + 200 + 16777215 + + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Please specify the sample groups in the experiment. Each group may have several measurement files and represents the different conditions in the experiment (e.g. different conditions and controls, different genotypes)</p><p>Double click on a group to edit it</p></body></html> + + + Qt::AlignJustify|Qt::AlignVCenter + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 200 + 0 + + + + + + + + + + font: 10pt; + + + Experiment + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 80 + + + + + + + + font: 30pt "Calibri"; + + + + LC-HRMS data + + + + + + + QFrame::Sunken + + + Qt::Vertical + + + + + + + QFrame::Sunken + + + Qt::Vertical + + + + + + + font: 30pt "Calibri"; + + + + Experiment description + + + + + + + font: 10pt; + + + Operator + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QFrame::Sunken + + + Qt::Vertical + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + font: 10pt; + + + Scan event(s) + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + + 0 + 0 + + + + + 200 + 16777215 + + + + true + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Please select the appropriate scan event(s). MetExtract is able to work with one positive and one negative scan events at a time. If only one ionisation mode should be used, select empty for the opposite mode.<br/><br/>Note: only those scan event common among all loaded measurement files are available. In case no common scan events are present, the calculation cannot be started.</p></body></html> + + + true + + + + + + + + + + + font: 10pt; + + + Define/Edit group + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 0 + 0 + + + + + + + + + + + + + false + + + Process + + + + + + + 0 + 0 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Qt::Horizontal + + + + 475 + 20 + + + + + + + + font: 30pt "Calibri"; + + + + <html><head/><body><p><span style=" font-size:16pt;">Run task(s)</span></p></body></html> + + + + + + + Keep one core unused + + + true + + + + + + + CPU cores + + + + + + + 1 + + + + + + + Start + + + + + + + + + + QFrame::Plain + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 1889 + 1965 + + + + + 0 + 0 + + + + + + + Qt::LeftToRight + + + font: 30pt "Calibri"; + + + + 2nd step: Multiple files annotation + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + If checked, MetExtract will search for features in the input files + + + Qt::LeftToRight + + + font: 30pt "Calibri"; + + + + 1st step: Individual files processing + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 50 + + + + + + + + Qt::LeftToRight + + + font: 30pt "Calibri"; + + + + 3rd step: Annotate metabolites + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 50 + + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 50 + + + + + + + + font: 30pt "Calibri"; + + + + 4th step: Generate MSMS target lists + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 0 + 0 + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Please specify the settings for your experiment</p><p>The section &quot;Labeling&quot; provides options for describing the experiment's stable isotopic labeling.</p><p>The section &quot;MZ picking&quot; and &quot;MZ clustering&quot; describe the parameters of your HRMS device</p><p>The section &quot;Chromatographic separation&quot; describes your LC device and are parameters used for peak picking</p><p>The results of each measurement is automatically saved in tabular format to &lt;FileName&gt;.tsv. Additionally the results can be saved as &lt;FileName&gt;.pdf with graphical illustrations. </p><p>For some LC-HRMS devices it is also possible to save &lt;FileName&gt;.mzXML files. These files only include those mass peaks originating from the labeling process</p></body></html> + + + Qt::AlignJustify|Qt::AlignVCenter + + + true + + + + + + + Visual config + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 200 + 40 + + + + + + + + + + Qt::Vertical + + + + + + + + + 9 + + + 9 + + + + + Save results + + + + 3 + + + 3 + + + 3 + + + 3 + + + 2 + + + + + New mzXML file + + + true + + + false + + + + + + M + + + true + + + + + + + M+1 + + + + + + + ... + + + + + + + M'-1 + + + + + + + M' + + + true + + + + + + + + + + PDF + + + true + + + false + + + + + + + TSV + + + true + + + false + + + + + + + FeatureML (basic) + + + true + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 3 + 0 + + + + + + + + + + + + Post processing + + + + + + + Qt::Horizontal + + + + + + + + + + + Correct C-Count + + + + + + + Isotopolog ratios + + + + + + 1 + + + + + + + Native + + + + + + + Labeled + + + + + + + -99 + + + 0 + + + 1 + + + -1 + + + + + + + Moiety + + + + + + + 1 + + + + + + + + + + Hetero isotopologue annotation + + + false + + + + + + + + + 1 + + + 3 + + + + + + + Found in scans + + + + + + + ± + + + % + + + 100.000000000000000 + + + 15.000000000000000 + + + + + + + Intensity deviation + + + + + + + Heteroatoms configuration + + + + + + + + + + Feature convolution + + + false + + + + + + Pearson correlation + + + + + + + + + + % + + + -100.000000000000000 + + + 100.000000000000000 + + + 5.000000000000000 + + + 85.000000000000000 + + + + + + + Relationship configuration + + + + + + + Number of connected features pairs + + + + + + + + + + % + + + 100.000000000000000 + + + 0.100000000000000 + + + 40.000000000000000 + + + + + + + Simplify in-source fragments + + + true + + + + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 20 + 0 + + + + + + + + + + + + + + + + + 0 + 0 + + + + Chromatographic separation + + + + + + + Qt::Horizontal + + + + + + + MZ clustering + + + + + + scans + + + + + + 1 + + + 9999 + + + 3 + + + + + + + Minimum spectra + + + + + + + Clustering window + + + + + + + + + + ppm + + + 1.000000000000000 + + + 8.000000000000000 + + + + + + + + + + + + XIC extraction + + + + + + Smoothing window + + + + + + + Identified M/Z Peaks are binned together using hierachical Clustering. This Parameter defines how far apart these M/Z Peaks may be + + + EIC width + + + + + + + + 0 + 0 + + + + + 130 + 0 + + + + + None + + + + + Triangle + + + + + Flat + + + + + Gaussian + + + + + Hanning + + + + + SavitzkyGolay + + + + + + + + ± + + + ppm + + + 5.000000000000000 + + + + + + + + + + Polynom + + + + + + + Window size + + + + + + + scans + + + ± + + + 1 + + + 99 + + + + + + + + + + Chromatographic separation + + + + + + + + + scans + + + 0 + + + 3.000000000000000 + + + + + + + Minimum scale + + + + + + + Maximum scale + + + + + + + + + + scans + + + 0 + + + 1000.000000000000000 + + + 19.000000000000000 + + + + + + + SNR threshold + + + + + + + + + + + + + 3 + + + + + + + + + + Peak matching + + + + + + scans + + + + + + 5 + + + + + + + Scale error + + + + + + + + + + % + + + -100.000000000000000 + + + 100.000000000000000 + + + 5.000000000000000 + + + 85.000000000000000 + + + + + + + Center error + + + + + + + Minimum corr + + + + + + + + + + 3 + + + + + + + EIC M' artificial shift + + + + + + + + + scans + + + + + + -100 + + + 100 + + + + + + + + 0 + 0 + + + + - + + + + + + + scans + + + -100 + + + 100 + + + + + + + + + + + + Required M:M' peak area ratio + + + true + + + false + + + + + + + + + 4 + + + 0.000100000000000 + + + + + + + + + + 999999.000000000000000 + + + 9999.000000000000000 + + + + + + + Minimum ratio + + + + + + + Maximum ratio + + + + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 20 + 0 + + + + + + + + + + Qt::Vertical + + + + + + + Qt::Vertical + + + + + + + 6 + + + + + + + Labeling options + + + Labeling + + + + + + + Qt::Horizontal + + + + + + + + + Labeling + + + + + + + Isotope N + + + + + + + + + 0 + 0 + + + + true + + + + + + + + 0 + 0 + + + + Isotope N + + + 3 + + + + + + + + 0 + 0 + + + + Purity of the Base Growth Media + + + Isotopic enrichment + + + 3 + + + + + + + + 0 + 0 + + + + % + + + 2 + + + 100.000000000000000 + + + 0.100000000000000 + + + 98.930000000000007 + + + + + + + + 0 + 0 + + + + - + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 3 + + + + + + + + + + + + Isotope L + + + + + + + + + 0 + 0 + + + + - + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 3 + + + + + + + + 0 + 0 + + + + Isotope L + + + 3 + + + + + + + + 0 + 0 + + + + true + + + + + + + + 0 + 0 + + + + Purity of the Isotope Growth Media + + + Isotopic enrichment + + + 3 + + + + + + + + 0 + 0 + + + + % + + + 2 + + + 100.000000000000000 + + + 0.100000000000000 + + + 99.510000000000005 + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + Use Carbon-isotopepattern validation + + + false + + + + + + + Required M:M' signal ratio + + + true + + + false + + + + + + + + Minimum ratio + + + + + + + Maximum ratio + + + + + + + + + + 4 + + + 9999.989999999999782 + + + 0.100000000000000 + + + + + + + true + + + + + + 999999.989999999990687 + + + 0.100000000000000 + + + 9999.000000000000000 + + + + + + + + + + + + + + + 0 + 0 + + + + Click to specify used Xenobiotics or tracers for the metabolisation study + + + Tracer setup + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + No tracers configured + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 200 + 0 + + + + + + + + + + Qt::Vertical + + + + + + + + + + + + 0 + 0 + + + + MZ picking + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + + + + + + 0 + 0 + + + + Number of labeling (X) atoms to search for + + + + + + + + + 12-15, 30, 45 + + + + + + + + + + + 0 + 0 + + + + Scan range + + + + + + + + From + + + + + + + minutes + + + 9999.989999999999782 + + + 25.000000000000000 + + + + + + + To + + + + + + + + 0 + 11 + + + + + 16777215 + 16777215 + + + + minutes + + + 9999.989999999999782 + + + 3.000000000000000 + + + + + + + + + + + + Signal abundance + + + + + + true + + + + + + 0 + + + 1000000000.000000000000000 + + + 1000.000000000000000 + + + 1000.000000000000000 + + + + + + + Intensity Threshold for the pure Base M/Z Peak +This threshold is not used for mixed Base and Isotope Peaks or for +the pure Isotope Peak + + + Intensity threshold + + + + + + + Intensity cutoff + + + + + + + true + + + + + + + + + 100000000 + + + 1000 + + + + + + + + + + Charge + + + + + + Number of charges + + + 3 + + + + + + + + + + 1 + + + 3 + + + 2 + + + + + + + + + + Mass deviation + + + + + + Ppm Range in which the Isotope M/Z Peak and the mixed Base and Intensity Peaks can be found + + + Mass deviation + + + 3 + + + + + + + ± + + + ppm + + + 2.000000000000000 + + + + + + + + + + Isotopologs + + + + + + The observed Intensities differ from the theoretical Intensities for a certain M/Z. This parameter controls the deviation of the theoretical and observed Intensities for a mixed Base and Isotope M/Z Peak + + + Maximum ratio deviation native + + + + + + + ± + + + % + + + 200.000000000000000 + + + 1.000000000000000 + + + 25.000000000000000 + + + + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><style type="text/css"> +p, li { white-space: pre-wrap; } +</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">The observed Intensities differ from the theoretical Intensities for a certain M/Z. This parameter controls the deviation of the theoretical and observed Intensities for a mixed Base and Isotope M/Z Peak</span></p></body></html> + + + Maximum ratio deviation labeled + + + + + + + ± + + + % + + + 200.000000000000000 + + + 1.000000000000000 + + + 25.000000000000000 + + + + + + + The number of M/Z Peaks that are required for a Base M/Z or an Isotope M/Z to be marked as labeled + + + Number of isotopologs native + + + 3 + + + + + + + Number of isotopologs labeled + + + 3 + + + + + + + = + + + 2 + + + 2 + + + + + + + Consider isotopolog abundance + + + + + + + + + + = + + + 1 + + + 2 + + + 2 + + + + + + + true + + + + + + 0 + + + 1000000000.000000000000000 + + + 1000.000000000000000 + + + 1000.000000000000000 + + + + + + + Isotopolog threshold + + + + + + + Scan index offset for labeled signal search. When set to 0, both native and labeled forms are searched in the same scan. A negative value n means the labeled form is searched n scans before the current native scan, a positive value means n scans after. + + + Labeled scan offset + + + 3 + + + + + + + Scan index offset for labeled signal search. 0 = same scan as native. Negative = labeled signal searched in earlier scans, positive = later scans. + + + = + + + -100 + + + 100 + + + 0 + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + Qt::Horizontal + + + + + + + + + + + + QFrame::Raised + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + + 1550 + 0 + + + + Qt::Horizontal + + + + + + + QFrame::Raised + + + Qt::Horizontal + + + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Qt::Vertical + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Detected metabolites will be used to generate one or severl lists of MSMS targets for targeted fragmentation experiments</p><p>Separate lists will be generated for the positive and negative ionization modes</p></body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + Minimum required abundance + + + + + + + true + + + + + + 1 + + + 1000000000 + + + 10000 + + + 100000 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + MSMSM target file location + + + + + + + + 0 + 0 + + + + + 700 + 0 + + + + + + + + Select file + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + Retention time window for MSMS targets + + + + + + + ± + + + minutes + + + 1000.000000000000000 + + + 0.250000000000000 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + Maximum number of parallel MSMS targets + + + + + + + + + + 1 + + + 5 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + Number of MSMS replicates per sample + + + + + + + 1 + + + 2 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + Heuristic optimisation: Generation: + + + + + + + Number of offsprings per generation + + + + + + + 1 + + + 20 + + + + + + + Number of generations + + + + + + + 1 + + + 5000 + + + 500 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + 0 + + + + + 16777215 + 300 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Qt::Vertical + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 5 + + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Detected and bracketed results will be annotated with putative sum formulas (Seven Golden Rules Kind et al. 2007) and/or metabolite databases (in the form of TSV lists)</p></body></html> + + + Qt::AlignJustify|Qt::AlignVCenter + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::MinimumExpanding + + + + 20 + 0 + + + + + + + + + + + + Generate sum formulas + + + true + + + + + + Elements + + + + + + Minimum + + + + + + + + 0 + 0 + + + + CHNOS + + + + + + + Maximum + + + + + + + + 0 + 0 + + + + C80H300N10O20S10 + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 0 + 0 + + + + + + + + + + + Annotation databases + + + true + + + + + + + + Check retention time + + + + + + + Maximum retention time deviation + + + + + + + ± + + + minutes + + + 0.150000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Add database + + + + + + + false + + + Add mzVault repository + + + + + + + Remove annotation source + + + + + + + Qt::Vertical + + + + + + + Generate database template + + + + + + + + + + 0 + 0 + + + + + 50 + 30 + + + + + 16777215 + 70 + + + + + + + + + + + Minimum MSMS similarity score + + + + + + + + 80 + 0 + + + + + + + 1.000000000000000 + + + 0.700000000000000 + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 40 + 20 + + + + + + + + + + + + + + + Maximum allowed mass deviation + + + + + + + ± + + + ppm + + + 3.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Correct mass by + + + + + + + positive mode + + + + + + + ppm + + + -20.000000000000000 + + + 20.000000000000000 + + + 0.500000000000000 + + + + + + + negative mode + + + + + + + ppm + + + -20.000000000000000 + + + 20.000000000000000 + + + 0.500000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Use number of labeling atoms + + + + + + + + Exact + + + + + Don't use + + + + + Minimum + + + + + PlusMinus + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 40 + 20 + + + + + + + + + + + + 0 + 0 + + + + + 0 + 100 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Qt::Vertical + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 0 + 5 + + + + + + + + + 200 + 0 + + + + + 200 + 16777215 + + + + color: rgb(90, 90, 90); +font: 7pt; + + + <html><head/><body><p>Features found in several measurement files may be grouped together (optional: chromatographic alignment)<br/><br/>Not detected feature pairs (e.g. due to low intensity) from other measurement files may be integrated in a targeted fashion</p></body></html> + + + Qt::AlignJustify|Qt::AlignVCenter + + + true + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 0 + + + + + + + + + + + + + 0 + 0 + + + + Bracket results + + + true + + + + + + + + Maximum mz width + + + + + + + ± + + + ppm + + + 0.010000000000000 + + + 4.000000000000000 + + + + + + + + + + + Align chromatograms + + + true + + + + + + + Qt::Vertical + + + + + + + n-th polynom + + + + + + + 1 + + + 99 + + + 1 + + + + + + + + + + + Time window + + + + + + + ± + + + minutes + + + 3 + + + 0.010000000000000 + + + 0.100000000000000 + + + + + + + + + + + + + 0 + 0 + + + + Convolute results + + + true + + + + + + + + Maximum time window + + + + + + + ± + + + minutes + + + 0.050000000000000 + + + + + + + Minimum connections + + + + + + + + + + % + + + 100.000000000000000 + + + 40.000000000000000 + + + + + + + Minimum number of detections in files + + + + + + + + + + + + + 0 + + + 999 + + + 1 + + + + + + + Use SIL ratio + + + + + + + Use abundance similarity + + + true + + + + + + + Abundance similarity threshold + + + + + + + + + + % + + + 100.000000000000000 + + + 85.000000000000000 + + + + + + + + + + + + + 0 + 0 + + + + Integrate missing feature pairs + + + true + + + + + + + + Maximum time difference + + + + + + + ± + + + minutes + + + 2 + + + 0.100000000000000 + + + + + + + Intensity cutoff + + + + + + + true + + + + + + + + + 100000000 + + + 1000 + + + + + + + + + + + + Export peak areas + + + true + + + + + + + Export peak apex intensity + + + + + + + Export peak SNR + + + + + + + Qt::Horizontal + + + + + + + + + Save grouped results to + + + + + + + + 0 + 0 + + + + + 700 + 0 + + + + + + + + Select file + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + 0 + 0 + + + + QFrame::Raised + + + Qt::Horizontal + + + + + + + + + + + + false + + + Sample results + + + + 0 + + + 0 + + + 0 + + + 0 + + + 2 + + + + + + + + + + 255 + 255 + 255 + + + + + + + 240 + 240 + 240 + + + + + + + + + 255 + 255 + 255 + + + + + + + 240 + 240 + 240 + + + + + + + + + 240 + 240 + 240 + + + + + + + 240 + 240 + 240 + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 1201 + 1387 + + + + + + + Qt::Horizontal + + + false + + + false + + + + + + + Sort according to + + + + + + + + 0 + 0 + + + + + 400 + 0 + + + + QAbstractItemView::ExtendedSelection + + + true + + + 11 + + + 75 + + + 30 + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + 5 + + + + + 6 + + + + + 7 + + + + + 8 + + + + + 9 + + + + + 10 + + + + + 11 + + + + + + + + + + + 0 + 0 + + + + Processed file + + + + + + + 30 + + + true + + + + + + + + + + + + 0 + 0 + + + + Filter + + + + + + + + 0 + 0 + + + + + + + + + + + + + 0 + 0 + + + + Result name + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + 20 + 0 + + + + + 28 + 16777215 + + + + Set + + + + + + + + + + RT + + + + + M/Z + + + + + Intensity + + + + + Peaks correlation + + + + + + + + Open externally + + + + + + + + + + + 0 + + + + EICs and Mass spectra + + + + + + QFrame::NoFrame + + + 0 + + + 1 + + + Qt::Vertical + + + true + + + + + + + + + <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">EICs of native and labeled metabolite ion</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Show options + + + + + + + + 0 + 0 + + + + Hide options + + + + + + + + + + 0 + 0 + + + + + 700 + 350 + + + + + + + + Options + + + + + + Autozoom to chromatographic peaks + + + true + + + + + + + Show EIC of labeled ion form with negative intensities + + + true + + + + + + + Add balloon labels to the EIC plot + + + true + + + + + + + Color peak area + + + false + + + + + + + Shift the labeled EIC artificially + + + + + + + Normalise peak abundances to 1 + + + true + + + + + + + Normalise native and labeled peaks separately + + + + + + + Show EICs of isotopologs M+1 and M'-1 + + + + + + + Subtract the baseline of the EICs + + + false + + + + + + + Show smoothed signal + + + + + + + Crop EICs to chromatographic peaks + + + + + + + Show legend + + + true + + + + + + + Qt::Horizontal + + + + + + + Add labels to other features detected with the same m/z value + + + + + + + Set peak centers to 0 to improve their comparison + + + + + + + + + + + + + + + 0 + 0 + + + + + 700 + 350 + + + + + + + + QFrame::Raised + + + Qt::Horizontal + + + + + + + + + <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Mass spectra of native and labeled metabolite ion</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Show options + + + + + + + + 0 + 0 + + + + Hide options + + + + + + + + + Options + + + + + + Labels + + + true + + + + + + + Isotopologues + + + true + + + + + + + M-1, M'+1 + + + true + + + + + + + + + Isotopolog deviation + + + + + + + ± + + + ppm + + + 1 + + + 5.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + Group results + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + QFrame::Raised + + + Qt::Horizontal + + + + + + + + + + + + + + + + + + + + false + + + Experiment results + + + + + + + + + + + 255 + 255 + 255 + + + + + + + 240 + 240 + 240 + + + + + + + + + 255 + 255 + 255 + + + + + + + 240 + 240 + 240 + + + + + + + + + 240 + 240 + 240 + + + + + + + 240 + 240 + 240 + + + + + + + + QFrame::NoFrame + + + QFrame::Plain + + + true + + + + + 0 + 0 + 1353 + 776 + + + + + + + Options + + + + + + + + EIC + + + + + + + ± + + + ppm + + + 1 + + + 1.000000000000000 + + + 5.000000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Peak width + + + + + + + ± + + + minutes + + + 0.500000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Normalise separately + + + + + + + Normalise to labeled features + + + false + + + + + + + + + + + + + 0 + 0 + + + + Export all as PDF + + + + + + + Qt::Horizontal + + + QSizePolicy::Preferred + + + + 40 + 20 + + + + + + + + + + + + <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Visualization of native and labeled metabolite forms in all samples</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Show options + + + + + + + + 0 + 0 + + + + Hide options + + + + + + + Show custom feature + + + + + + + + + + 0 + 0 + + + + + 500 + 0 + + + + QAbstractItemView::ExtendedSelection + + + 6 + + + 40 + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + + 0 + + + + Separated peaks + + + + + + + 0 + 0 + + + + + 700 + 500 + + + + + + + + + + + + Raw XICs + + + + + + + 0 + 0 + + + + + + + + + + Separate according to + + + + + + + + Group + + + + + Sample + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Abundance profiles + + + + + + + + Visualization + + + + + + + + Boxplot + + + + + Line plot + + + + + + + + Scale + + + + + + + + Linear + + + + + Logarithmic + + + + + + + + Normalization + + + + + + + + None + + + + + Scale to max sample + + + + + Scale to max experimental group + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 0 + 0 + + + + + 700 + 500 + + + + + + + + + + + + + + + + + + + + + 0 + 0 + + + + color:grey; + + + TextLabel + + + Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing + + + + + + + + + 0 + 0 + 1954 + 21 + + + + + Help + + + + + + + + File + + + + Load Settings + + + + + + + + + + + + Tools + + + + + + + + + + + + + + + + About + + + + + Exit + + + + + Load from file + + + + + Save Settings + + + + + Help + + + + + Isotopic enrichment + + + + + Set working directory + + + + + Open temporary directory (logfile and caches) + + + + + Show overview of results + + + + + exExperimentName_LineEdit + exOperator_LineEdit + exExperimentID_LineEdit + exComments_TextEdit + groupsList + addGroup + positiveScanEvent + negativeScanEvent + isotopeAText + isotopicAbundanceA + isotopeBText + isotopicAbundanceB + useCValidation + minRatio + maxRatio + setupTracers + xCountSearch + scanStartTime + scanEndTime + clustPPM + minSpectraCount + wavelet_EICppm + eicSmoothingWindow + eicSmoothingWindowSize + smoothingPolynom_spinner + wavelet_minScale + wavelet_maxScale + wavelet_SNRThreshold + peak_centerError + peak_scaleError + minPeakCorr + doubleSpinBox_minPeakRatio + doubleSpinBox_maxPeakRatio + correctcCount + calcIsoRatioNative_spinBox + calcIsoRatioLabelled_spinBox + calcIsoRatioMoiety_spinBox + hAIntensityError + hAMinScans + defineHeteroAtoms + minCorrelation + minCorrelationConnections + relationshipConfig + saveCSV + savePDF + saveMZXML + wm_ia + wm_iap + wm_imb + wm_ib + groupResults + groupPpm + groupingRT + alignChromatograms + polynomValue + minConnectionRate + metaboliteClusterMinConnections + useAbundanceSimilarityForConvolution + abundanceSimilarityThreshold + integratedMissedPeaks + integrationMaxTimeDifference + reintegrateIntensityCutoff + groupsSelectFile + workingCore + cpuCores + startIdentification + dataFilter + tabWidget_2 + scrollArea + resultsExperiment_TreeWidget + tabWidget_3 + setChromPeakName + visualConfig + scrollArea_2 + res_ExtractedData + saveGroups + loadGroups + removeGroup + scrollArea_3 + groupsSave + chromPeakName + processedFilesComboBox + + + + + + + pushButton_showOptions1 + clicked() + groupBox_options1 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions1 + clicked() + pushButton_hideOptions1 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions1 + clicked() + pushButton_showOptions1 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions1 + clicked() + groupBox_options1 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions1 + clicked() + pushButton_showOptions1 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions1 + clicked() + pushButton_hideOptions1 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions2 + clicked() + groupBox_options2 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions2 + clicked() + pushButton_hideOptions2 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions2 + clicked() + pushButton_showOptions2 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions2 + clicked() + groupBox_options2 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions2 + clicked() + pushButton_showOptions2 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions2 + clicked() + pushButton_hideOptions2 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions3 + clicked() + groupBox_options3 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions3 + clicked() + pushButton_hideOptions3 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_showOptions3 + clicked() + pushButton_showOptions3 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions3 + clicked() + groupBox_options3 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions3 + clicked() + pushButton_showOptions3 + show() + + + 20 + 20 + + + 20 + 20 + + + + + pushButton_hideOptions3 + clicked() + pushButton_hideOptions3 + hide() + + + 20 + 20 + + + 20 + 20 + + + + + diff --git a/src/mePyGuis/mainWindow.py b/src/mePyGuis/mainWindow.py index 4b7c9aa..44348ae 100644 --- a/src/mePyGuis/mainWindow.py +++ b/src/mePyGuis/mainWindow.py @@ -1679,6 +1679,14 @@ def setupUi(self, MainWindow): self.generateDBTemplate_pushButton = QtWidgets.QPushButton(self.searchDB_checkBox) self.generateDBTemplate_pushButton.setObjectName(_fromUtf8("generateDBTemplate_pushButton")) self.horizontalLayout_23.addWidget(self.generateDBTemplate_pushButton) + self.line_37 = QtWidgets.QFrame(self.searchDB_checkBox) + self.line_37.setFrameShape(QtWidgets.QFrame.VLine) + self.line_37.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line_37.setObjectName(_fromUtf8("line_37")) + self.horizontalLayout_23.addWidget(self.line_37) + self.testDBs_pushButton = QtWidgets.QPushButton(self.searchDB_checkBox) + self.testDBs_pushButton.setObjectName(_fromUtf8("testDBs_pushButton")) + self.horizontalLayout_23.addWidget(self.testDBs_pushButton) self.gridLayout_51.addLayout(self.horizontalLayout_23, 3, 0, 1, 7) self.dbList_listView = QtWidgets.QListView(self.searchDB_checkBox) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) @@ -2850,6 +2858,8 @@ def setupUi(self, MainWindow): self.actionSet_working_directory.setObjectName(_fromUtf8("actionSet_working_directory")) self.actionDownload_OBO_files = QtGui.QAction(MainWindow) self.actionDownload_OBO_files.setObjectName(_fromUtf8("actionDownload_OBO_files")) + self.actionDownloadDBTemplate = QtGui.QAction(MainWindow) + self.actionDownloadDBTemplate.setObjectName(_fromUtf8("actionDownloadDBTemplate")) self.openTempDir = QtGui.QAction(MainWindow) self.openTempDir.setObjectName(_fromUtf8("openTempDir")) self.actionShow_summary_of_previous_current_results = QtGui.QAction(MainWindow) @@ -2867,6 +2877,7 @@ def setupUi(self, MainWindow): self.menuTools.addSeparator() self.menuTools.addAction(self.actionSet_working_directory) self.menuTools.addAction(self.actionDownload_OBO_files) + self.menuTools.addAction(self.actionDownloadDBTemplate) self.menuTools.addSeparator() self.menuTools.addAction(self.openTempDir) self.menuTools.addSeparator() @@ -3286,6 +3297,7 @@ def retranslateUi(self, MainWindow): self.addmzVaultRep_pushButton.setText(_translate("MainWindow", "Add mzVault repository", None)) self.removeDB_pushButton.setText(_translate("MainWindow", "Remove annotation source", None)) self.generateDBTemplate_pushButton.setText(_translate("MainWindow", "Generate database template", None)) + self.testDBs_pushButton.setText(_translate("MainWindow", "Test DBs", None)) self.label_79.setText(_translate("MainWindow", "Maximum retention time deviation", None)) self.maxRTErrorInHits_spinnerBox.setPrefix(_translate("MainWindow", "± ", None)) self.maxRTErrorInHits_spinnerBox.setSuffix(_translate("MainWindow", " minutes", None)) @@ -3517,6 +3529,7 @@ def retranslateUi(self, MainWindow): self.actionIsotopic_enrichment.setText(_translate("MainWindow", "Isotopic enrichment", None)) self.actionSet_working_directory.setText(_translate("MainWindow", "Set working directory", None)) self.actionDownload_OBO_files.setText(_translate("MainWindow", "Download OBO files", None)) + self.actionDownloadDBTemplate.setText(_translate("MainWindow", "Download database template", None)) self.openTempDir.setText(_translate("MainWindow", "Open temporary directory (logfile and caches)", None)) self.actionShow_summary_of_previous_current_results.setText(_translate("MainWindow", "Show overview of results", None)) diff --git a/src/resultsPostProcessing/searchDatabases.py b/src/resultsPostProcessing/searchDatabases.py index 1e38fca..bb52d4f 100644 --- a/src/resultsPostProcessing/searchDatabases.py +++ b/src/resultsPostProcessing/searchDatabases.py @@ -2,7 +2,6 @@ import sys import csv from copy import deepcopy -from math import ceil from ..formulaTools import formulaTools import logging from .. import LoggingSetup @@ -83,7 +82,7 @@ def __init__(self): self.dbEntriesNeutral = [] self.dbEntriesMZ = [] - def addEntriesFromFile(self, dbName, dbFile, callBackCheckFunction=None): + def addEntriesFromFile(self, dbName, dbFile, callBackCheckFunction=None, error_collector=None): imported = 0 notImported = 0 @@ -92,7 +91,7 @@ def addEntriesFromFile(self, dbName, dbFile, callBackCheckFunction=None): fT = formulaTools() # check if file exists - + if not os.path.exists(dbFile): logging.error("DB import error: File not found %s" % (dbFile)) raise Exception("DB import error: File not found %s" % (dbFile)) @@ -145,7 +144,10 @@ def _iter_rows(): try: rt_min = float(row[headers["Rt_min"]]) if row[headers["Rt_min"]] != "" else None except Exception: - logging.error(" - DB (%s) import error (row %d): The Rt_min value '%s' of the entry %s '%s' could not be parsed as a float, not using RT for this compound" % (dbName, rowi, row[headers["Rt_min"]], num, name)) + _msg = " - Error row %d: The Rt_min value '%s' of the entry %s '%s' could not be parsed as a float, not using RT for this compound" % (rowi, row[headers["Rt_min"]], num, name) + logging.error(_msg) + if error_collector is not None: + error_collector.append(_msg) mz = float(row[headers["MZ"]]) if row[headers["MZ"]] != "" else None polarity = row[headers["IonisationMode"]].strip().replace('"', "DOURBLEPRIME").replace("'", "PRIME").replace("\t", "TAB").replace("\n", "RETURN").replace("\r", "CarrRETURN").replace("#", "HASH") additionalInfo = {} @@ -176,7 +178,10 @@ def _iter_rows(): entry_polarity = "+" if formula_charge > 0 else "-" is_charged_formula = True except Exception: - logging.error(" - DB (%s) import error (row %d): The sumformula (%s) of the entry %s '%s' could not be parsed" % (dbName, rowi, sumFormula, num, name)) + _msg = " - Error row %d: The sumformula (%s) of the entry %s '%s' could not be parsed" % (rowi, sumFormula, num, name) + logging.error(_msg) + if error_collector is not None: + error_collector.append(_msg) notImported += 1 dbEntry = DBEntry( @@ -206,18 +211,26 @@ def _iter_rows(): imported += 1 except Exception as ex: - logging.error(" - DB (%s) import error (row %d): %s" % (dbName, rowi, ex)) + _msg = " - Error row %d: %s" % (dbName, rowi, ex) + logging.error(_msg) + if error_collector is not None: + error_collector.append(_msg) notImported += 1 - logging.info( - " - Imported DB %s with %d entries" - % ( - dbName, - len(self.dbEntriesMZ) + len(self.dbEntriesNeutral) - curEntriesCount, - ) - ) if notImported > 0: - logging.error("Warning: Not imported %d entries (see above errors)" % (notImported)) + _summary_msg = "Warning: Not imported %d entries (see above errors)" % (notImported) + logging.error(_summary_msg) + if error_collector is not None: + error_collector.append(_summary_msg) + + _summary_msg = " - Imported DB %s with %d entries" % ( + dbName, + len(self.dbEntriesMZ) + len(self.dbEntriesNeutral) - curEntriesCount, + ) + logging.info(_summary_msg) + if error_collector is not None: + error_collector.append(_summary_msg) + return imported, notImported def optimizeDB(self): @@ -229,7 +242,7 @@ def _findGeneric(self, list, getValue, valueLeft, valueRight): return [] # implement binary search to find a value in the sorted list between valueLeft and valueRight - left = 0 + left = 0 right = len(list) - 1 while left <= right: From 35e65244a776fc76fdfde46c13b0f287b491357e Mon Sep 17 00:00:00 2001 From: chrboku Date: Fri, 15 May 2026 14:58:04 +0200 Subject: [PATCH 21/27] minor adaption --- src/MExtract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MExtract.py b/src/MExtract.py index 89f4aa0..5b0eae0 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -1838,7 +1838,7 @@ def loadGroupsResultsFile(self, groupsResFile): # show a dialog with a drop-down list asking the user to specify the table to load options = self.experimentResults.db_con.list_tables() - options = [opt for opt in options if opt not in ["Parameters", "__dTypes__", "2_StatColumns_FalsePositives", "2_StatColumns_Omitted", "4_Convoluted_doublePeaks", "5_Annotated_Compounds", "5_Annotated_SumFormulas"]][::-1] + options = [opt for opt in options if opt not in ["Parameters", "__dTypes__", "2_StatColumns_FalsePositives", "2_StatColumns_Omitted", "4_Convoluted_doublePeaks", "5_Annotated_Compounds", "5_Annotated_SumFormulas", "0_sampleStats", "DB_info"]][::-1] mgsBox = QtWidgets.QMessageBox(self) mgsBox.setWindowTitle("Select results to load") From 81bcc9e308c656b722b359598d77702995c33a0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 15:24:32 +0000 Subject: [PATCH 22/27] Add MSMS similarity tools, overview, and flexible MGF export Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/aa3339f6-eecb-4686-9b1d-7281d251c412 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- .../experiment-results-msms-tools.png | Bin 0 -> 45291 bytes pyproject.toml | 1 + src/MExtract.py | 455 +++++++++++++++++- 3 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 docs/screenshots/experiment-results-msms-tools.png diff --git a/docs/screenshots/experiment-results-msms-tools.png b/docs/screenshots/experiment-results-msms-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..043e2177354842a6e3b01bb84bc072dd758aa90b GIT binary patch literal 45291 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 307e287..2793e9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dependencies = [ "context>=0.1.0", "polars>=1.39.3", "desktop-notifier>=6.2.0", + "matchms>=0.33.0", ] [project.optional-dependencies] diff --git a/src/MExtract.py b/src/MExtract.py index 5b0eae0..64b5d0c 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -47,6 +47,7 @@ from PySide6.QtWidgets import QCheckBox, QComboBox, QHBoxLayout, QPushButton, QTableWidgetItem, QWidget import matplotlib.patches as patches import matplotlib.pyplot as plt +import numpy as np from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure @@ -89,6 +90,15 @@ from .resultsPostProcessing import searchDatabases as searchDatabases from .formulaTools import formulaTools, getElementOfIsotope, getIsotopeMass +try: + from matchms import Spectrum as MatchmsSpectrum + from matchms.similarity import CosineGreedy as MatchmsCosineGreedy + from matchms.filtering import normalize_intensities as matchms_normalize_intensities + + MATCHMS_AVAILABLE = True +except Exception: + MATCHMS_AVAILABLE = False + app = None # Set local folder for MetExtract II @@ -9385,6 +9395,9 @@ def __lt__(self, other): "labeled_mz_max": labeled_mz_max, "per_file_rt": per_file_rt, "global_rt": bd.rt, # seconds, used as fallback + "feature_num": getattr(bd, "id", None), + "metabolite_group_id": getattr(bd, "metaboliteGroupID", None), + "xn": getattr(bd, "xn", None), } ) @@ -9411,20 +9424,20 @@ def __lt__(self, other): # Check precursor m/z against native form if fr["native_mz_min"] <= ms2_scan.precursor_mz <= fr["native_mz_max"]: - all_ms2_scans.append({"scan": ms2_scan, "form": "native", "file": file_key}) + all_ms2_scans.append({"scan": ms2_scan, "form": "native", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metabolite_group_id"), "xn": fr.get("xn")}) break # Check against labeled form if fr["labeled_mz_min"] <= ms2_scan.precursor_mz <= fr["labeled_mz_max"]: - all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key}) + all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metabolite_group_id"), "xn": fr.get("xn")}) break - temp_list = [(s["scan"], s["form"], s["file"]) for s in all_ms2_scans] + temp_list = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] temp_list = natSort(temp_list, key=lambda x: x[0].precursor_intensity) _native_color = QtGui.QColor(30, 144, 255, 60) _labeled_color = QtGui.QColor(178, 34, 34, 60) - for scan, form, file_key in temp_list: + for scan, form, file_key, feature_num, o_group, xn in temp_list: form_label = "M\u2032" if form == "labeled" else "M" row_idx = tbl.rowCount() tbl.insertRow(row_idx) @@ -9435,6 +9448,10 @@ def __lt__(self, other): nl_item = _NSItem(form_label) nl_item.setData(QtCore.Qt.UserRole, scan) nl_item.setData(QtCore.Qt.UserRole + 1, form) + nl_item.setData(QtCore.Qt.UserRole + 2, feature_num) + nl_item.setData(QtCore.Qt.UserRole + 3, o_group) + nl_item.setData(QtCore.Qt.UserRole + 4, file_key) + nl_item.setData(QtCore.Qt.UserRole + 5, xn) tbl.setItem(row_idx, 0, nl_item) tbl.setItem(row_idx, 1, _NSItem(f"{scan.precursor_intensity:.4g}")) tbl.setItem(row_idx, 2, _NSItem(f"{scan.precursor_mz:.4f}")) @@ -9892,6 +9909,415 @@ def plotSelectedMSMSSpectra_exp(self): self._setup_msms_hover(self.ui.plMSMS_exp) self.ui.plMSMS_exp.canvas.draw() + def _iter_exp_msms_rows(self): + tbl = self.ui.msms_SpectraList_exp + for row_idx in range(tbl.rowCount()): + col0 = tbl.item(row_idx, 0) + if col0 is None: + continue + scan = col0.data(QtCore.Qt.UserRole) + form = col0.data(QtCore.Qt.UserRole + 1) + feature_num = col0.data(QtCore.Qt.UserRole + 2) + o_group = col0.data(QtCore.Qt.UserRole + 3) + file_key = col0.data(QtCore.Qt.UserRole + 4) + xn = col0.data(QtCore.Qt.UserRole + 5) + if scan is None: + continue + yield { + "row": row_idx, + "scan": scan, + "form": form, + "feature_num": feature_num, + "o_group": o_group, + "file_key": file_key, + "xn": xn, + } + + def _to_matchms_spectrum(self, scan): + if not MATCHMS_AVAILABLE: + return None + if scan is None or len(scan.mz_list) == 0: + return None + spec = MatchmsSpectrum( + mz=np.asarray(scan.mz_list, dtype=float), + intensities=np.asarray(scan.intensity_list, dtype=float), + metadata={"precursor_mz": float(scan.precursor_mz), "retention_time": float(scan.retention_time)}, + ) + return matchms_normalize_intensities(spec) + + def _show_msms_similarity_dialog(self, form_filter): + if not MATCHMS_AVAILABLE: + QtWidgets.QMessageBox.warning(self, "MS/MS similarity", "matchms is not available in this environment.") + return + + rows = [r for r in self._iter_exp_msms_rows() if r.get("form") == form_filter and r.get("feature_num") is not None] + by_feature = defaultdict(list) + for row in rows: + by_feature[row["feature_num"]].append(row) + feature_ids = sorted(by_feature.keys()) + if len(feature_ids) < 2: + QtWidgets.QMessageBox.information(self, "MS/MS similarity", "At least two features with MS/MS spectra are required.") + return + + repr_scans = {} + for fid in feature_ids: + repr_scans[fid] = max(by_feature[fid], key=lambda x: float(getattr(x["scan"], "precursor_intensity", 0.0)))["scan"] + + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle(f"MS/MS similarity ({'native' if form_filter == 'native' else 'labeled'})") + dlg.resize(980, 760) + layout = QtWidgets.QVBoxLayout(dlg) + + ctrl = QtWidgets.QHBoxLayout() + ctrl.addWidget(QtWidgets.QLabel("Similarity threshold:")) + threshold_spin = QtWidgets.QDoubleSpinBox() + threshold_spin.setRange(0.0, 1.0) + threshold_spin.setDecimals(3) + threshold_spin.setSingleStep(0.05) + threshold_spin.setValue(0.7) + ctrl.addWidget(threshold_spin) + ctrl.addStretch(1) + layout.addLayout(ctrl) + + matrix = QtWidgets.QTableWidget() + matrix.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + matrix.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems) + matrix.setRowCount(len(feature_ids)) + matrix.setColumnCount(len(feature_ids)) + labels = [f"Num {fid}" for fid in feature_ids] + matrix.setVerticalHeaderLabels(labels) + matrix.setHorizontalHeaderLabels(labels) + layout.addWidget(matrix, 1) + + mirror = QtCore.QObject() + mirror.fig = Figure((5.0, 3.0), dpi=80, facecolor="white") + mirror.canvas = FigureCanvas(mirror.fig) + mirror.axes = mirror.fig.add_subplot(111) + layout.addWidget(mirror.canvas, 1) + + cosine = MatchmsCosineGreedy(tolerance=0.01) + pair_cache = {} + + def _paint(): + threshold = threshold_spin.value() + for i, fid_a in enumerate(feature_ids): + for j, fid_b in enumerate(feature_ids): + if j < i: + continue + if fid_a == fid_b: + score = 1.0 + pair_cache[(i, j)] = (repr_scans[fid_a], repr_scans[fid_b], score) + else: + sp_a = self._to_matchms_spectrum(repr_scans[fid_a]) + sp_b = self._to_matchms_spectrum(repr_scans[fid_b]) + if sp_a is None or sp_b is None: + score = 0.0 + else: + try: + score = float(cosine.pair(sp_a, sp_b).get("score", 0.0)) + except Exception: + score = 0.0 + pair_cache[(i, j)] = (repr_scans[fid_a], repr_scans[fid_b], score) + pair_cache[(j, i)] = pair_cache[(i, j)] + + item = QtWidgets.QTableWidgetItem(f"{score:.3f}") + item.setTextAlignment(QtCore.Qt.AlignCenter) + red = int(255 * (1.0 - score)) + green = int(255 * score) + item.setBackground(QtGui.QColor(red, green, 80)) + if score >= threshold: + item.setForeground(QtGui.QBrush(QtGui.QColor("black"))) + else: + item.setForeground(QtGui.QBrush(QtGui.QColor("white"))) + matrix.setItem(i, j, item) + if i != j: + matrix.setItem(j, i, QtWidgets.QTableWidgetItem(item)) + matrix.resizeColumnsToContents() + + def _show_selected_pair(): + idxs = matrix.selectedIndexes() + if len(idxs) == 0: + return + i, j = idxs[0].row(), idxs[0].column() + if (i, j) not in pair_cache: + return + scan_a, scan_b, score = pair_cache[(i, j)] + mirror.fig.clear() + ax = mirror.fig.add_subplot(111) + ax.vlines(scan_a.mz_list, 0, scan_a.intensity_list, colors="dodgerblue", linewidth=1.2) + ax.vlines(scan_b.mz_list, 0, -np.asarray(scan_b.intensity_list), colors="firebrick", linewidth=1.2) + ax.axhline(0, color="black", linewidth=0.8) + ax.set_xlabel("m/z") + ax.set_ylabel("Intensity (mirror)") + ax.set_title(f"Num {feature_ids[i]} vs Num {feature_ids[j]} | similarity={score:.3f}") + ax.grid(True, alpha=0.2) + mirror.fig.tight_layout() + mirror.canvas.draw() + + threshold_spin.valueChanged.connect(_paint) + matrix.itemSelectionChanged.connect(_show_selected_pair) + _paint() + if matrix.rowCount() > 0 and matrix.columnCount() > 0: + matrix.setCurrentCell(0, 0) + _show_selected_pair() + + dlg.exec() + + def _copy_msms_spectrum(self, scan, fmt): + if scan is None: + return + lines = [] + if fmt == "list": + lines = [f"{float(mz):.6f} {float(it):.6f}" for mz, it in zip(scan.mz_list, scan.intensity_list)] + elif fmt == "tsv": + lines = ["mz\tintensity"] + [f"{float(mz):.6f}\t{float(it):.6f}" for mz, it in zip(scan.mz_list, scan.intensity_list)] + else: # massbank-like + lines = [f"{float(mz):.6f}\t{float(it):.6f}" for mz, it in zip(scan.mz_list, scan.intensity_list)] + pyperclip.copy("\n".join(lines)) + + def _show_msms_context_menu(self, table_widget, pos): + item = table_widget.itemAt(pos) + if item is None: + return + row = item.row() + col0 = table_widget.item(row, 0) + if col0 is None: + return + scan = col0.data(QtCore.Qt.UserRole) + menu = QtWidgets.QMenu(table_widget) + act_list = menu.addAction("Copy spectrum (m/z intensity list)") + act_tsv = menu.addAction("Copy spectrum (TSV)") + act_mb = menu.addAction("Copy spectrum (MassBank style)") + sel = menu.exec(table_widget.mapToGlobal(pos)) + if sel == act_list: + self._copy_msms_spectrum(scan, "list") + elif sel == act_tsv: + self._copy_msms_spectrum(scan, "tsv") + elif sel == act_mb: + self._copy_msms_spectrum(scan, "massbank") + + def _export_msms_mgf(self): + rows = list(self._iter_exp_msms_rows()) + if len(rows) == 0: + QtWidgets.QMessageBox.information(self, "MS/MS export", "No MS/MS spectra available for export.") + return + + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle("Export MS/MS to MGF") + form = QtWidgets.QVBoxLayout(dlg) + grid = QtWidgets.QFormLayout() + mode = QtWidgets.QComboBox() + mode.addItems(["Raw spectra", "Average spectrum per feature", "Most abundant spectrum per feature", "Cleaned spectrum per feature"]) + grid.addRow("Export mode:", mode) + include_native = QtWidgets.QCheckBox("Export native spectra") + include_native.setChecked(True) + include_labeled = QtWidgets.QCheckBox("Export labeled spectra") + include_labeled.setChecked(True) + grid.addRow(include_native) + grid.addRow(include_labeled) + allow_zero_label = QtWidgets.QCheckBox("Cleaning: allow 0 labeling atoms") + allow_zero_label.setChecked(True) + grid.addRow(allow_zero_label) + separate_collision = QtWidgets.QCheckBox("Create separate files for each collision setup") + grid.addRow(separate_collision) + collision_list = QtWidgets.QListWidget() + collision_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) + collision_keys = sorted({f"{getattr(r['scan'], 'filter_line', '')}|CE={getattr(r['scan'], 'collisionEnergy', '')}" for r in rows}) + for ck in collision_keys: + it = QtWidgets.QListWidgetItem(ck) + it.setFlags(it.flags() | QtCore.Qt.ItemIsUserCheckable) + it.setCheckState(QtCore.Qt.Checked) + collision_list.addItem(it) + grid.addRow("Collision setups:", collision_list) + form.addLayout(grid) + btns = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + form.addWidget(btns) + btns.accepted.connect(dlg.accept) + btns.rejected.connect(dlg.reject) + if dlg.exec() != QtWidgets.QDialog.Accepted: + return + + forms = set() + if include_native.isChecked(): + forms.add("native") + if include_labeled.isChecked(): + forms.add("labeled") + if len(forms) == 0: + QtWidgets.QMessageBox.warning(self, "MS/MS export", "Select at least one isotopolog form.") + return + + save_path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save MS/MS MGF", "msms_export.mgf", "MGF file (*.mgf)") + if save_path == "": + return + + selected_collision = {collision_list.item(i).text() for i in range(collision_list.count()) if collision_list.item(i).checkState() == QtCore.Qt.Checked} + selected = [r for r in rows if r["form"] in forms and f"{getattr(r['scan'], 'filter_line', '')}|CE={getattr(r['scan'], 'collisionEnergy', '')}" in selected_collision] + if len(selected) == 0: + QtWidgets.QMessageBox.information(self, "MS/MS export", "No spectra match the current export selection.") + return + + by_key = defaultdict(list) + for r in selected: + collision = f"{getattr(r['scan'], 'filter_line', '')}|CE={getattr(r['scan'], 'collisionEnergy', '')}" + if separate_collision.isChecked(): + by_key[(r["form"], collision)].append(r) + else: + by_key[(r["form"], "all")].append(r) + + label_offset = abs(getIsotopeMass(str(self.ui.isotopeBText.text())) - getIsotopeMass(str(self.ui.isotopeAText.text()))) + + def _clean_fragextract_like(native_scan, labeled_scan, xn): + if native_scan is None or labeled_scan is None: + return native_scan, labeled_scan + min_atoms = 0 if allow_zero_label.isChecked() else 1 + max_atoms = int(xn) if xn is not None else 12 + max_atoms = max(min_atoms, max_atoms) + best_n = min_atoms + best_score = -1 + n_mz = np.asarray(native_scan.mz_list, dtype=float) + l_mz = np.asarray(labeled_scan.mz_list, dtype=float) + n_it = np.asarray(native_scan.intensity_list, dtype=float) + l_it = np.asarray(labeled_scan.intensity_list, dtype=float) + for n in range(min_atoms, max_atoms + 1): + shift = n * label_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + score = 0.0 + for mz, inten in zip(n_mz, n_it): + if np.any(np.abs((l_mz - mz) - shift) <= 0.01): + score += float(inten) + if score > best_score: + best_score = score + best_n = n + shift = best_n * label_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + keep_n = [] + keep_l = [] + for i, mz in enumerate(n_mz): + if np.any(np.abs((l_mz - mz) - shift) <= 0.01): + keep_n.append(i) + for i, mz in enumerate(l_mz): + if np.any(np.abs((mz - n_mz) - shift) <= 0.01): + keep_l.append(i) + cn = deepcopy(native_scan) + cl = deepcopy(labeled_scan) + if keep_n: + cn.mz_list = n_mz[keep_n] + cn.intensity_list = n_it[keep_n] + if keep_l: + cl.mz_list = l_mz[keep_l] + cl.intensity_list = l_it[keep_l] + return cn, cl + + def _representative_spectrum(entries): + if mode.currentText() == "Most abundant spectrum per feature": + return max(entries, key=lambda x: float(getattr(x["scan"], "precursor_intensity", 0.0)))["scan"] + if mode.currentText() == "Raw spectra": + return None + bins = defaultdict(float) + for e in entries: + scan = e["scan"] + for mz, inten in zip(scan.mz_list, scan.intensity_list): + mz_bin = round(float(mz), 3) + bins[mz_bin] += float(inten) + if len(bins) == 0: + return entries[0]["scan"] + mzs = sorted(bins.keys()) + ints = [bins[mz] / max(1, len(entries)) for mz in mzs] + rep = deepcopy(entries[0]["scan"]) + rep.mz_list = np.asarray(mzs, dtype=float) + rep.intensity_list = np.asarray(ints, dtype=float) + return rep + + written_files = [] + for (form_key, collision_key), vals in by_key.items(): + out_path = save_path + if len(by_key) > 1: + suffix = f"_{form_key}_{re.sub(r'[^A-Za-z0-9_.-]+', '_', collision_key)}" + out_path = save_path.replace(".mgf", f"{suffix}.mgf") + with open(out_path, "w", encoding="utf-8") as out: + if mode.currentText() == "Raw spectra": + export_entries = [[v] for v in vals] + else: + by_feature = defaultdict(list) + for v in vals: + by_feature[v["feature_num"]].append(v) + export_entries = [fe for fe in by_feature.values()] + for entries in export_entries: + if len(entries) == 0: + continue + feature_num = entries[0]["feature_num"] + o_group = entries[0]["o_group"] + scan = entries[0]["scan"] if mode.currentText() == "Raw spectra" else _representative_spectrum(entries) + if mode.currentText() == "Cleaned spectrum per feature": + native_entries = [x for x in selected if x["feature_num"] == feature_num and x["form"] == "native"] + labeled_entries = [x for x in selected if x["feature_num"] == feature_num and x["form"] == "labeled"] + n_scan = _representative_spectrum(native_entries) if native_entries else None + l_scan = _representative_spectrum(labeled_entries) if labeled_entries else None + n_scan, l_scan = _clean_fragextract_like(n_scan, l_scan, entries[0].get("xn")) + if form_key == "native" and n_scan is not None: + scan = n_scan + elif form_key == "labeled" and l_scan is not None: + scan = l_scan + out.write("BEGIN IONS\n") + out.write(f"TITLE=Num_{feature_num}_OGroup_{o_group}_{form_key}\n") + out.write(f"PEPMASS={float(getattr(scan, 'precursor_mz', 0.0)):.6f}\n") + out.write(f"RTINSECONDS={float(getattr(scan, 'retention_time', 0.0)):.3f}\n") + out.write(f"FEATURE_NUM={feature_num}\n") + out.write(f"OGROUP={o_group}\n") + out.write(f"FORM={form_key}\n") + out.write(f"FILTER_LINE={getattr(scan, 'filter_line', '')}\n") + out.write(f"COLLISION_ENERGY={getattr(scan, 'collisionEnergy', '')}\n") + for mz, inten in zip(scan.mz_list, scan.intensity_list): + out.write(f"{float(mz):.6f} {float(inten):.6f}\n") + out.write("END IONS\n\n") + written_files.append(out_path) + + QtWidgets.QMessageBox.information(self, "MS/MS export", "Exported:\n" + "\n".join(written_files)) + + def _show_msms_overview(self): + rows = list(self._iter_exp_msms_rows()) + by_feature = defaultdict(lambda: {"native": 0, "labeled": 0}) + for r in rows: + if r["feature_num"] is None: + continue + by_feature[r["feature_num"]][r["form"]] += 1 + if len(by_feature) == 0: + QtWidgets.QMessageBox.information(self, "MS/MS overview", "No feature-linked MS/MS spectra available.") + return + + dlg = QtWidgets.QDialog(self) + dlg.setWindowTitle("MS/MS overview") + dlg.resize(640, 420) + layout = QtWidgets.QVBoxLayout(dlg) + table = QtWidgets.QTableWidget() + table.setColumnCount(4) + table.setHorizontalHeaderLabels(["Feature Num", "Native scans", "Labeled scans", "Total"]) + table.setRowCount(len(by_feature)) + for i, fid in enumerate(sorted(by_feature.keys())): + n = by_feature[fid]["native"] + l = by_feature[fid]["labeled"] + table.setItem(i, 0, QtWidgets.QTableWidgetItem(str(fid))) + table.setItem(i, 1, QtWidgets.QTableWidgetItem(str(n))) + table.setItem(i, 2, QtWidgets.QTableWidgetItem(str(l))) + table.setItem(i, 3, QtWidgets.QTableWidgetItem(str(n + l))) + table.resizeColumnsToContents() + layout.addWidget(table) + fig = Figure((5.0, 2.5), dpi=80, facecolor="white") + canvas = FigureCanvas(fig) + ax = fig.add_subplot(111) + fids = sorted(by_feature.keys()) + native_counts = [by_feature[f]["native"] for f in fids] + labeled_counts = [by_feature[f]["labeled"] for f in fids] + x = np.arange(len(fids)) + ax.bar(x - 0.2, native_counts, width=0.4, color="dodgerblue", label="Native") + ax.bar(x + 0.2, labeled_counts, width=0.4, color="firebrick", label="Labeled") + ax.set_xticks(x) + ax.set_xticklabels([str(f) for f in fids], rotation=90) + ax.set_xlabel("Feature Num") + ax.set_ylabel("MS/MS scan count") + ax.legend(loc="upper right") + fig.tight_layout() + layout.addWidget(canvas) + dlg.exec() + #
# @@ -12605,6 +13031,27 @@ def __init__(self, module="TracExtract", parent=None, silent=False, disableR=Fal # resultsExperimentChangedNew is used only by showCustomFeature (loads raw mzXML); # normal tree selection uses resultsExperimentChanged (reads pre-computed per-file DBs) self.ui.msms_SpectraList_exp.itemSelectionChanged.connect(self.plotSelectedMSMSSpectra_exp) + self.ui.msms_SpectraList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.ui.msms_SpectraList_exp.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.ui.msms_SpectraList.customContextMenuRequested.connect(lambda pos: self._show_msms_context_menu(self.ui.msms_SpectraList, pos)) + self.ui.msms_SpectraList_exp.customContextMenuRequested.connect(lambda pos: self._show_msms_context_menu(self.ui.msms_SpectraList_exp, pos)) + + # Additional MS/MS controls in experiment-results tab + self.ui.msms_controls_exp = QtWidgets.QHBoxLayout() + self.ui.btn_msms_similarity_native = QtWidgets.QPushButton("Native similarity") + self.ui.btn_msms_similarity_labeled = QtWidgets.QPushButton("Labeled similarity") + self.ui.btn_msms_overview = QtWidgets.QPushButton("MS/MS overview") + self.ui.btn_msms_export_mgf = QtWidgets.QPushButton("Export MGF") + self.ui.msms_controls_exp.addWidget(self.ui.btn_msms_similarity_native) + self.ui.msms_controls_exp.addWidget(self.ui.btn_msms_similarity_labeled) + self.ui.msms_controls_exp.addWidget(self.ui.btn_msms_overview) + self.ui.msms_controls_exp.addWidget(self.ui.btn_msms_export_mgf) + self.ui.msms_controls_exp.addStretch(1) + self.ui.verticalLayout_msms_exp.insertLayout(1, self.ui.msms_controls_exp) + self.ui.btn_msms_similarity_native.clicked.connect(lambda: self._show_msms_similarity_dialog("native")) + self.ui.btn_msms_similarity_labeled.clicked.connect(lambda: self._show_msms_similarity_dialog("labeled")) + self.ui.btn_msms_overview.clicked.connect(self._show_msms_overview) + self.ui.btn_msms_export_mgf.clicked.connect(self._export_msms_mgf) self.ui.showCustomFeature_pushButton.clicked.connect(self.showCustomFeature) From bcba628211a6194a71572c117ab68ccf1d3588b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 15:26:04 +0000 Subject: [PATCH 23/27] Polish MSMS export/similarity code review feedback Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/aa3339f6-eecb-4686-9b1d-7281d251c412 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 64b5d0c..2c336f6 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -9396,7 +9396,7 @@ def __lt__(self, other): "per_file_rt": per_file_rt, "global_rt": bd.rt, # seconds, used as fallback "feature_num": getattr(bd, "id", None), - "metabolite_group_id": getattr(bd, "metaboliteGroupID", None), + "metaboliteGroupID": getattr(bd, "metaboliteGroupID", None), "xn": getattr(bd, "xn", None), } ) @@ -9424,11 +9424,11 @@ def __lt__(self, other): # Check precursor m/z against native form if fr["native_mz_min"] <= ms2_scan.precursor_mz <= fr["native_mz_max"]: - all_ms2_scans.append({"scan": ms2_scan, "form": "native", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metabolite_group_id"), "xn": fr.get("xn")}) + all_ms2_scans.append({"scan": ms2_scan, "form": "native", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metaboliteGroupID"), "xn": fr.get("xn")}) break # Check against labeled form if fr["labeled_mz_min"] <= ms2_scan.precursor_mz <= fr["labeled_mz_max"]: - all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metabolite_group_id"), "xn": fr.get("xn")}) + all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metaboliteGroupID"), "xn": fr.get("xn")}) break temp_list = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] @@ -10031,7 +10031,14 @@ def _paint(): item.setForeground(QtGui.QBrush(QtGui.QColor("white"))) matrix.setItem(i, j, item) if i != j: - matrix.setItem(j, i, QtWidgets.QTableWidgetItem(item)) + sym_item = QtWidgets.QTableWidgetItem(f"{score:.3f}") + sym_item.setTextAlignment(QtCore.Qt.AlignCenter) + sym_item.setBackground(QtGui.QColor(red, green, 80)) + if score >= threshold: + sym_item.setForeground(QtGui.QBrush(QtGui.QColor("black"))) + else: + sym_item.setForeground(QtGui.QBrush(QtGui.QColor("white"))) + matrix.setItem(j, i, sym_item) matrix.resizeColumnsToContents() def _show_selected_pair(): @@ -10164,7 +10171,7 @@ def _export_msms_mgf(self): else: by_key[(r["form"], "all")].append(r) - label_offset = abs(getIsotopeMass(str(self.ui.isotopeBText.text())) - getIsotopeMass(str(self.ui.isotopeAText.text()))) + isotope_mass_offset = abs(getIsotopeMass(str(self.ui.isotopeBText.text())) - getIsotopeMass(str(self.ui.isotopeAText.text()))) def _clean_fragextract_like(native_scan, labeled_scan, xn): if native_scan is None or labeled_scan is None: @@ -10179,7 +10186,7 @@ def _clean_fragextract_like(native_scan, labeled_scan, xn): n_it = np.asarray(native_scan.intensity_list, dtype=float) l_it = np.asarray(labeled_scan.intensity_list, dtype=float) for n in range(min_atoms, max_atoms + 1): - shift = n * label_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + shift = n * isotope_mass_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) score = 0.0 for mz, inten in zip(n_mz, n_it): if np.any(np.abs((l_mz - mz) - shift) <= 0.01): @@ -10187,7 +10194,7 @@ def _clean_fragextract_like(native_scan, labeled_scan, xn): if score > best_score: best_score = score best_n = n - shift = best_n * label_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + shift = best_n * isotope_mass_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) keep_n = [] keep_l = [] for i, mz in enumerate(n_mz): @@ -10257,7 +10264,7 @@ def _representative_spectrum(entries): elif form_key == "labeled" and l_scan is not None: scan = l_scan out.write("BEGIN IONS\n") - out.write(f"TITLE=Num_{feature_num}_OGroup_{o_group}_{form_key}\n") + out.write(f"TITLE=Num_{feature_num}_OGROUP_{o_group}_{form_key}\n") out.write(f"PEPMASS={float(getattr(scan, 'precursor_mz', 0.0)):.6f}\n") out.write(f"RTINSECONDS={float(getattr(scan, 'retention_time', 0.0)):.3f}\n") out.write(f"FEATURE_NUM={feature_num}\n") From 930446ad57a7bc7143e93cd906bef11ff617bb25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 15:27:42 +0000 Subject: [PATCH 24/27] Refine MSMS export code readability and consistency Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/aa3339f6-eecb-4686-9b1d-7281d251c412 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 2c336f6..488f3f0 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -99,6 +99,8 @@ except Exception: MATCHMS_AVAILABLE = False +FILENAME_SAFE_PATTERN = r"[^A-Za-z0-9_.-]+" + app = None # Set local folder for MetExtract II @@ -9431,13 +9433,13 @@ def __lt__(self, other): all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metaboliteGroupID"), "xn": fr.get("xn")}) break - temp_list = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] - temp_list = natSort(temp_list, key=lambda x: x[0].precursor_intensity) + ms2_scans_with_metadata = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] + ms2_scans_with_metadata = natSort(ms2_scans_with_metadata, key=lambda x: x[0].precursor_intensity) _native_color = QtGui.QColor(30, 144, 255, 60) _labeled_color = QtGui.QColor(178, 34, 34, 60) - for scan, form, file_key, feature_num, o_group, xn in temp_list: + for scan, form, file_key, feature_num, o_group, xn in ms2_scans_with_metadata: form_label = "M\u2032" if form == "labeled" else "M" row_idx = tbl.rowCount() tbl.insertRow(row_idx) @@ -10171,7 +10173,10 @@ def _export_msms_mgf(self): else: by_key[(r["form"], "all")].append(r) - isotope_mass_offset = abs(getIsotopeMass(str(self.ui.isotopeBText.text())) - getIsotopeMass(str(self.ui.isotopeAText.text()))) + isotope_mass_offset = getattr(self, "_cached_isotope_mass_offset", None) + if isotope_mass_offset is None: + isotope_mass_offset = abs(getIsotopeMass(str(self.ui.isotopeBText.text())) - getIsotopeMass(str(self.ui.isotopeAText.text()))) + self._cached_isotope_mass_offset = isotope_mass_offset def _clean_fragextract_like(native_scan, labeled_scan, xn): if native_scan is None or labeled_scan is None: @@ -10181,12 +10186,13 @@ def _clean_fragextract_like(native_scan, labeled_scan, xn): max_atoms = max(min_atoms, max_atoms) best_n = min_atoms best_score = -1 + charge = max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) n_mz = np.asarray(native_scan.mz_list, dtype=float) l_mz = np.asarray(labeled_scan.mz_list, dtype=float) n_it = np.asarray(native_scan.intensity_list, dtype=float) l_it = np.asarray(labeled_scan.intensity_list, dtype=float) for n in range(min_atoms, max_atoms + 1): - shift = n * isotope_mass_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + shift = n * isotope_mass_offset / charge score = 0.0 for mz, inten in zip(n_mz, n_it): if np.any(np.abs((l_mz - mz) - shift) <= 0.01): @@ -10194,7 +10200,7 @@ def _clean_fragextract_like(native_scan, labeled_scan, xn): if score > best_score: best_score = score best_n = n - shift = best_n * isotope_mass_offset / max(1, int(getattr(native_scan, "precursorCharge", 1) or 1)) + shift = best_n * isotope_mass_offset / charge keep_n = [] keep_l = [] for i, mz in enumerate(n_mz): @@ -10237,7 +10243,7 @@ def _representative_spectrum(entries): for (form_key, collision_key), vals in by_key.items(): out_path = save_path if len(by_key) > 1: - suffix = f"_{form_key}_{re.sub(r'[^A-Za-z0-9_.-]+', '_', collision_key)}" + suffix = f"_{form_key}_{re.sub(FILENAME_SAFE_PATTERN, '_', collision_key)}" out_path = save_path.replace(".mgf", f"{suffix}.mgf") with open(out_path, "w", encoding="utf-8") as out: if mode.currentText() == "Raw spectra": From 8c77bf40d176f6b7070f732022f01cbda25ad9af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 15:29:12 +0000 Subject: [PATCH 25/27] Tidy MSMS helper naming and filename sanitization Agent-Logs-Url: https://github.com/chrboku/MetExtract-II/sessions/aa3339f6-eecb-4686-9b1d-7281d251c412 Co-authored-by: chrboku <18591761+chrboku@users.noreply.github.com> --- src/MExtract.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 488f3f0..50e0835 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -99,8 +99,6 @@ except Exception: MATCHMS_AVAILABLE = False -FILENAME_SAFE_PATTERN = r"[^A-Za-z0-9_.-]+" - app = None # Set local folder for MetExtract II @@ -117,6 +115,8 @@ TRACER = object() METABOLOME = object() +FILENAME_SAFE_PATTERN = r"[^A-Za-z0-9_.-]+" + # Boxplot layout constants for abundance-profile group comparison plots ABUNDANCE_BOXPLOT_CLUSTER_WIDTH = 0.75 ABUNDANCE_BOXPLOT_SLOT_FILL_RATIO = 0.8 @@ -167,6 +167,10 @@ def safe_pickle_loads(data, default_value=None, operation_name="loading data"): return default_value +def sanitize_filename(text): + return re.sub(FILENAME_SAFE_PATTERN, "_", str(text)) + + # # # @@ -9433,13 +9437,13 @@ def __lt__(self, other): all_ms2_scans.append({"scan": ms2_scan, "form": "labeled", "file": file_key, "feature_num": fr.get("feature_num"), "o_group": fr.get("metaboliteGroupID"), "xn": fr.get("xn")}) break - ms2_scans_with_metadata = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] - ms2_scans_with_metadata = natSort(ms2_scans_with_metadata, key=lambda x: x[0].precursor_intensity) + sorted_scans = [(s["scan"], s["form"], s["file"], s.get("feature_num"), s.get("o_group"), s.get("xn")) for s in all_ms2_scans] + sorted_scans = natSort(sorted_scans, key=lambda x: x[0].precursor_intensity) _native_color = QtGui.QColor(30, 144, 255, 60) _labeled_color = QtGui.QColor(178, 34, 34, 60) - for scan, form, file_key, feature_num, o_group, xn in ms2_scans_with_metadata: + for scan, form, file_key, feature_num, o_group, xn in sorted_scans: form_label = "M\u2032" if form == "labeled" else "M" row_idx = tbl.rowCount() tbl.insertRow(row_idx) @@ -10243,7 +10247,7 @@ def _representative_spectrum(entries): for (form_key, collision_key), vals in by_key.items(): out_path = save_path if len(by_key) > 1: - suffix = f"_{form_key}_{re.sub(FILENAME_SAFE_PATTERN, '_', collision_key)}" + suffix = f"_{form_key}_{sanitize_filename(collision_key)}" out_path = save_path.replace(".mgf", f"{suffix}.mgf") with open(out_path, "w", encoding="utf-8") as out: if mode.currentText() == "Raw spectra": From e8fa61427c1c716b20a2d5a1dafbf9550d7272a4 Mon Sep 17 00:00:00 2001 From: chrboku Date: Tue, 19 May 2026 13:13:32 +0200 Subject: [PATCH 26/27] - improved summary dialog - improved chromatographic peak picking dialog - improved and renamed in findIsoPairs_matchPartners.py - several other bugfixes and improvements --- src/MExtract.py | 623 ++-- src/findIsoPairs_matchPartners.py | 666 ++-- src/mePyGuis/PeakPickingSettingsDialog.py | 74 +- src/mePyGuis/ResultsSummaryDialog.py | 278 ++ src/runIdentification.py | 3404 --------------------- uv.lock | 427 ++- 6 files changed, 1314 insertions(+), 4158 deletions(-) create mode 100644 src/mePyGuis/ResultsSummaryDialog.py delete mode 100644 src/runIdentification.py diff --git a/src/MExtract.py b/src/MExtract.py index 50e0835..6fda8a6 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -83,7 +83,6 @@ from .annotateResultMatrix import performGroupOmit as grpOmit from .bracketResults import bracketResults, calculateMetaboliteGroups, compute_sample_stats from .mePyGuis.mainWindow import Ui_MainWindow -from .mePyGuis.QScrollableMessageBox import QScrollableMessageBox from .mePyGuis.TracerEdit import ConfiguredTracer, tracerEdit from .MSMS import optimizeMSMSTargets from .reIntegration import reIntegrateResultsFile @@ -1629,13 +1628,14 @@ def saveGroups(self, groupFile=None, saveSettings=False): for group in self.getAllSampleGroups(): grps.beginGroup(group.name) - for i in range(len(group.files)): - try: - relFilePath = "./" + str(os.path.relpath(group.files[i], os.path.split(str(groupFile))[0]).replace("\\", "/")) - except ValueError: - # Files are on different drives, use absolute path - relFilePath = str(group.files[i]).replace("\\", "/") - grps.setValue(group.name + "__" + str(i), relFilePath) + # for i in range(len(group.files)): + # try: + # relFilePath = "./" + str(os.path.relpath(group.files[i], os.path.split(str(groupFile))[0]).replace("\\", "/")) + # except ValueError: + # # Files are on different drives, use absolute path + # relFilePath = str(group.files[i]).replace("\\", "/") + # grps.setValue(group.name + "__" + str(i), relFilePath) + grps.setValue("files", ";".join(group.files)) grps.setValue("Min_Peaks_Found", group.minFound) grps.setValue("OmitFeatures", group.omitFeatures) grps.setValue("RemoveAsFalsePositive", group.removeAsFalsePositive) @@ -1768,6 +1768,14 @@ def loadGroups(self, groupFile=None, forceLoadSettings=False, askLoadSettings=Tr removeAsFalsePositive = self.to_bool(grps.value(kid)) elif str(kid) == "useAsMSMSTarget": useAsMSMSTarget = self.to_bool(grps.value(kid)) + elif str(kid) == "files": + files = str(grps.value(kid)).split(";") + for file_i in range(len(files)): + file = files[file_i] + if os.path.isabs(file.replace("\\", "/")): + kids.append(file.replace("\\", "/")) + else: + kids.append(os.path.split(str(groupFile))[0].replace("\\", "/") + "/" + file.replace("\\", "/")) elif str(kid).startswith(grp): if os.path.isabs(str(grps.value(kid)).replace("\\", "/")): kids.append(str(grps.value(kid)).replace("\\", "/")) @@ -3960,81 +3968,84 @@ def runProcess(self, dontSave=False, askStarting=True): completed = res._index if completed == len(files): loop = False - else: - pwMain("value")(completed) - - mess = {} - while not (queue.empty()): - mes = queue.get(block=False, timeout=1) - if mes.pid not in mess: - mess[mes.pid] = {} - mess[mes.pid][mes.mes] = mes - - for v in mess.values(): - if "start" in v.keys(): - mes = v["start"] - if len(freeSlots) > 0: - w = freeSlots.pop() - assignedThreads[mes.pid] = w - - pw.getCallingFunction()("statuscolor")(pIds[mes.pid], "orange") - pw.getCallingFunction()("statustext")( - pIds[mes.pid], - text="File: %s\nStatus: %s\nProcess ID: %d" % (pIds[mes.pid], "processing", mes.pid), - ) - else: - logging.error("Something went wrong..") - logging.error('Progress bars do not work correctly, but files will be processed and "finished.." will be printed..') - - for v in mess.values(): - for mes in v.values(): - if mes.mes == "log": - messages_to_print[mes.pid].append(mes.val) - elif mes.mes in ["text", "max", "value"]: - if mes.pid in assignedThreads: - pw.getCallingFunction(assignedThreads[mes.pid])(mes.mes)(mes.val) - else: - logging.error("Error in messaging pipeline of subprocess id %d" % mes.pid) - elif mes.mes in ["end", "failed", "start"]: - pass - else: - logging.error(f"Received unknown message {mes.mes} with payload {mes.__dict__}") - - for v in mess.values(): - if "end" in v.keys() or "failed" in v.keys(): - mes = None - if "end" in v.keys(): - mes = v["end"] - elif "failed" in v.keys(): - mes = v["failed"] - failedFiles.append(pIds[mes.pid]) - freeS = assignedThreads[mes.pid] - pw.getCallingFunction(assignedThreads[mes.pid])("text")("") - pw.getCallingFunction(assignedThreads[mes.pid])("value")(0) - - logging.info("\n##############################################################") - logging.info("\n".join(messages_to_print[mes.pid])) - logging.info("##############################################################\n") - - pw.getCallingFunction()("statuscolor")( - pIds[mes.pid], - "olivedrab" if mes.mes == "end" else "firebrick", - ) + + pwMain("value")(completed) + + mess = {} + while not (queue.empty()): + mes = queue.get(block=False, timeout=1) + if mes.pid not in mess: + mess[mes.pid] = {} + mess[mes.pid][mes.mes] = mes + + for v in mess.values(): + if "start" in v.keys(): + mes = v["start"] + if len(freeSlots) > 0: + w = freeSlots.pop() + assignedThreads[mes.pid] = w + + pw.getCallingFunction()("statuscolor")(pIds[mes.pid], "orange") pw.getCallingFunction()("statustext")( pIds[mes.pid], - text="File: %s\nStatus: %s" - % ( - pIds[mes.pid], - "finished" if mes.mes == "end" else "failed", - ), + text="File: %s\nStatus: %s\nProcess ID: %d" % (pIds[mes.pid], "processing", mes.pid), ) - - if freeS == -1: - logging.error("Something went wrong..") - logging.error('Progress bars do not work correctly, but files will be processed and "finished.." will be printed..') + else: + logging.error("Something went wrong..") + logging.error('Progress bars do not work correctly, but files will be processed and "finished.." will be printed..') + + for v in mess.values(): + for mes in v.values(): + if mes.mes == "log": + messages_to_print[mes.pid].append(mes.val) + elif mes.mes in ["text", "max", "value"]: + if mes.pid in assignedThreads: + pw.getCallingFunction(assignedThreads[mes.pid])(mes.mes)(mes.val) else: - assignedThreads[mes.pid] = -1 - freeSlots.append(freeS) + logging.error("Error in messaging pipeline of subprocess id %d" % mes.pid) + elif mes.mes in ["end", "failed", "start"]: + pass + else: + logging.error(f"Received unknown message {mes.mes} with payload {mes.__dict__}") + + for v in mess.values(): + if "end" in v.keys() or "failed" in v.keys(): + mes = None + if "end" in v.keys(): + mes = v["end"] + elif "failed" in v.keys(): + mes = v["failed"] + failedFiles.append(pIds[mes.pid]) + freeS = assignedThreads[mes.pid] + pw.getCallingFunction(assignedThreads[mes.pid])("text")("") + pw.getCallingFunction(assignedThreads[mes.pid])("value")(0) + + logging.info("\n" + + "##############################################################\n" + + "\n".join(messages_to_print[mes.pid])+ + "\n" + + "##############################################################\n"+ + "") + + pw.getCallingFunction()("statuscolor")( + pIds[mes.pid], + "olivedrab" if mes.mes == "end" else "firebrick", + ) + pw.getCallingFunction()("statustext")( + pIds[mes.pid], + text="File: %s\nStatus: %s" + % ( + pIds[mes.pid], + "finished" if mes.mes == "end" else "failed", + ), + ) + + if freeS == -1: + logging.error("Something went wrong..") + logging.error('Progress bars do not work correctly, but files will be processed and "finished.." will be printed..') + else: + assignedThreads[mes.pid] = -1 + freeSlots.append(freeS) elapsed = (time.time() - start) / 60.0 hours = "" @@ -4958,146 +4969,92 @@ def _fmt_elapsed(m): self.loadGroupsResultsFile(str(self.ui.groupsSave.text())) def showResultsSummary(self): - texts = [] + from .mePyGuis.ResultsSummaryDialog import ResultsSummaryDialog definedGroups = self.getAllSampleGroups() for group in definedGroups: for i in range(len(group.files)): group.files[i] = str(group.files[i]).replace("\\", "/") - # get all individual LC-HRMS files for processing - - maxFileNameLength = 10 - for group in definedGroups: - for file in natSort(group.files): - maxFileNameLength = max(maxFileNameLength, len(file)) - - texts.append("Note: \n") - texts.append("All calculated numbers shown here are based on the last data processing that was done for this dataset.\n") - texts.append("Thus, these calculated values are influenced and distorted by used parameters. \n") - texts.append("For example, an incorrect value for the parameter M:M' ratio will have a dramatic impact on the results and\n") - texts.append("consequently also on these number. Please only consider these numbers for a quick overview of the generated last results. \n") - texts.append("\n") - texts.append("Caution:\n") - texts.append("Do not use these numbers as the only means of further improving your data processing parameters. This could\n") - texts.append("potentially lead to a deadlock and an incorrect data processing with many false-positive results and/or false-negatives. \n") - texts.append("If you are unsure always try to contact an expert in data processing and/or ask a colleague of yours to\nhelp you with optimizing these values.\n\n\n\n") - texts.append("Results of individual files\n-=-=-=-=-=-=-=-=-=-=-=-=-=-\n\n") - texts.append( - ("%%%ds ionMode %%12s %%12s %%12s %%12s %%20s %%40s %%25s\n" % (maxFileNameLength)) - % ( - "File", - "MZs", - "MZ Bins", - "Features", - "Metabolites", - "mz delta ppm MZs", - "avg M:M' MZs; Area; Abundance", - "avg L-Enrichment", - ) - ) - indGroups = {} + # ── Collect per-file rows ────────────────────────────────────── + file_rows = [] for group in definedGroups: grName = str(group.name) - indGroups[grName] = [] - texts.append( - " Group " - + grName - + " [%d files%s%s%s%s, color %s]\n" - % ( - len(group.files), - ", Omit (minFound %d)" % group.minFound if group.omitFeatures else "", - ", use for grouping" if group.useForMetaboliteGrouping else "", - ", False positives remove", - ", use as MSMS targets" if group.useAsMSMSTarget else "", - group.color, - ) - ) - texts.append("%s\n" % ("-" * (2 + 6 + maxFileNameLength + 12 * 4 + 6 + 20 * 3 + 25))) + grColor = group.color if group.color else "" for file in natSort(group.files): - showFileName = True + if not os.path.exists(file + getDBSuffix()): + for ionMode in ["+", "-"]: + file_rows.append( + { + "group_name": grName, + "group_color": grColor, + "file": file, + "ion_mode": ionMode, + "error": "File not processed", + } + ) + continue for ionMode in ["+", "-"]: - indGroups[grName].append(str(file)) - - if os.path.exists(file + getDBSuffix()): - try: - file_db_con = PolarsDB(file + getDBSuffix(), format=getDBFormat()) - - # Count MZs for this ionMode - nMZs = len(file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode)) - nMZsPPMDelta = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).with_columns(((pl.col("lmz") - pl.col("mz") - pl.col("tmz")) * 1_000_000 / pl.col("mz")).alias("ppm_delta"))["ppm_delta"].mean() - - nMZsPPMDeltaStd = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).with_columns(((pl.col("lmz") - pl.col("mz") - pl.col("tmz")) * 1_000_000 / pl.col("mz")).alias("ppm_delta"))["ppm_delta"].std() - - nMZBins = file_db_con.tables["MZBins"].filter(pl.col("ionMode") == ionMode).shape[0] - - nFeatures = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).shape[0] - - nMetabolites = file_db_con.tables["featureGroups"].shape[0] - - avgRatioSignals = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).select((pl.col("intensity") / pl.col("intensityL")).alias("ratio"))["ratio"].mean() - - avgRatioSignalsStd = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).select((pl.col("intensity") / pl.col("intensityL")).alias("ratio"))["ratio"].std() - - avgRatioFeaturesArea = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).select((pl.col("NPeakArea") / pl.col("LPeakArea")).alias("area_ratio"))["area_ratio"].mean() - - avgRatioFeaturesAbundance = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).select((pl.col("NPeakAbundance") / pl.col("LPeakAbundance")).alias("abundance_ratio"))["abundance_ratio"].mean() - - avgEnrichmentL = file_db_con.tables["chromPeaks"].filter((pl.col("peaksRatioMPm1") > 0) & (pl.col("ionMode") == ionMode)).select((pl.col("xcount") / (pl.col("xcount") + pl.col("peaksRatioMPm1"))).alias("enrichment"))["enrichment"].mean() - - avgEnrichmentLStd = file_db_con.tables["chromPeaks"].filter((pl.col("peaksRatioMPm1") > 0) & (pl.col("ionMode") == ionMode)).select((pl.col("xcount") / (pl.col("xcount") + pl.col("peaksRatioMPm1"))).alias("enrichment"))["enrichment"].std() - - texts.append( - ("%%%ds %%s %%12s %%12s %%12s %%12s %%20s %%40s %%25s\n" % (maxFileNameLength)) - % ( - file if showFileName else "", - ionMode, - nMZs if nMZs > 0 else "", - nMZBins if nMZBins > 0 else "", - nFeatures if nFeatures > 0 else "", - nMetabolites if nMetabolites > 0 else "", - "%s (+/- %s)" - % ( - "%.2f" % nMZsPPMDelta if nMZsPPMDelta is not None else "", - "%.2f" % nMZsPPMDeltaStd if nMZsPPMDeltaStd is not None else "", - ) - if nMZsPPMDelta is not None - else "", - "%s; %s; %s" - % ( - "%6.2f (+/- %s)" - % ( - avgRatioSignals, - "%.2f" % avgRatioSignalsStd if avgRatioSignalsStd is not None else "", - ) - if avgRatioSignals is not None - else "-", - "%6.2f" % avgRatioFeaturesArea if avgRatioFeaturesArea is not None else "-", - "%6.2f" % avgRatioFeaturesAbundance if avgRatioFeaturesAbundance is not None else "-", - ) - if avgRatioSignalsStd is not None or avgRatioFeaturesArea is not None or avgRatioFeaturesAbundance is not None - else "", - "%.2f%% (+/- %s%%)" - % ( - 100 * avgEnrichmentL, - "%.2f" % (100 * avgEnrichmentLStd) if avgEnrichmentLStd is not None else "", - ) - if avgEnrichmentL is not None - else "", - ) - ) - showFileName = False - except Exception as ex: - texts.append(("%%%ds %%s Error reading file: %%s\n" % (maxFileNameLength)) % (file if showFileName else "", ionMode, str(ex))) - showFileName = False - else: - texts.append(("%%%ds File not processed\n" % (maxFileNameLength)) % file) - - texts.append("\n\n\n") + try: + file_db_con = PolarsDB(file + getDBSuffix(), format=getDBFormat()) + + nMZs = len(file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode)) + ppm_df = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).with_columns(((pl.col("lmz") - pl.col("mz") - pl.col("tmz")) * 1_000_000 / pl.col("mz")).alias("ppm_delta")) + nMZsPPMDelta = ppm_df["ppm_delta"].mean() + nMZsPPMDeltaStd = ppm_df["ppm_delta"].std() + + nMZBins = file_db_con.tables["MZBins"].filter(pl.col("ionMode") == ionMode).shape[0] + nFeatures = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).shape[0] + nMetabolites = file_db_con.tables["featureGroups"].shape[0] + + ratio_df = file_db_con.tables["MZs"].filter(pl.col("ionMode") == ionMode).select((pl.col("intensity") / pl.col("intensityL")).alias("ratio")) + avgRatioSignals = ratio_df["ratio"].mean() + avgRatioSignalsStd = ratio_df["ratio"].std() + + avgRatioFeaturesArea = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).select((pl.col("NPeakArea") / pl.col("LPeakArea")).alias("area_ratio"))["area_ratio"].mean() + + avgRatioFeaturesAbundance = file_db_con.tables["chromPeaks"].filter(pl.col("ionMode") == ionMode).select((pl.col("NPeakAbundance") / pl.col("LPeakAbundance")).alias("abundance_ratio"))["abundance_ratio"].mean() + + enr_df = file_db_con.tables["chromPeaks"].filter((pl.col("peaksRatioMPm1") > 0) & (pl.col("ionMode") == ionMode)).select((pl.col("xcount") / (pl.col("xcount") + pl.col("peaksRatioMPm1"))).alias("enrichment")) + avgEnrichmentL = enr_df["enrichment"].mean() + avgEnrichmentLStd = enr_df["enrichment"].std() + + file_rows.append( + { + "group_name": grName, + "group_color": grColor, + "file": file, + "ion_mode": ionMode, + "n_mzs": nMZs if nMZs > 0 else None, + "n_mz_bins": nMZBins if nMZBins > 0 else None, + "n_features": nFeatures if nFeatures > 0 else None, + "n_metabolites": nMetabolites if nMetabolites > 0 else None, + "mz_delta_mean": nMZsPPMDelta, + "mz_delta_std": nMZsPPMDeltaStd, + "avg_ratio_signals": avgRatioSignals, + "avg_ratio_signals_std": avgRatioSignalsStd, + "avg_ratio_area": avgRatioFeaturesArea, + "avg_ratio_abundance": avgRatioFeaturesAbundance, + "avg_enrichment": avgEnrichmentL, + "avg_enrichment_std": avgEnrichmentLStd, + "error": None, + } + ) + except Exception as ex: + file_rows.append( + { + "group_name": grName, + "group_color": grColor, + "file": file, + "ion_mode": ionMode, + "error": str(ex), + } + ) + # ── Collect bracketing / convoluted summary ──────────────────── + summary_data = {} resFileFull = str(self.ui.groupsSave.text()) if os.path.exists(resFileFull): - texts.append("Convoluted results\n-=-=-=-=-=-=-=-=-=-\n\n") try: res_db = PolarsDB(resFileFull, format="xlsx", load_all_tables=True) @@ -5107,82 +5064,54 @@ def showResultsSummary(self): convoluted_sheet = candidate break - if convoluted_sheet is None: - raise ValueError("No convoluted results sheet found in results file") - - table_df = res_db.tables[convoluted_sheet] + if convoluted_sheet is not None: + table_df = res_db.tables[convoluted_sheet] + + features = set() + metabolites = {} + metabolitesIonMode = {} + + for row in table_df.iter_rows(named=True): + num = row["Num"] + features.add(num) + ogroup = row["OGroup"] + if ogroup not in metabolites: + metabolites[ogroup] = [] + metabolites[ogroup].append(num) + if ogroup not in metabolitesIonMode: + metabolitesIonMode[ogroup] = set() + metabolitesIonMode[ogroup].add(row["Ionisation_Mode"]) + + summary_data["n_features"] = len(features) + summary_data["n_metabolites"] = len(metabolites) + summary_data["features_1"] = len([1 for k in metabolites if len(metabolites[k]) == 1]) + summary_data["features_2"] = len([1 for k in metabolites if len(metabolites[k]) == 2]) + summary_data["features_3"] = len([1 for k in metabolites if len(metabolites[k]) == 3]) + summary_data["features_4"] = len([1 for k in metabolites if len(metabolites[k]) == 4]) + summary_data["features_5"] = len([1 for k in metabolites if len(metabolites[k]) == 5]) + summary_data["features_5_to_10"] = len([1 for k in metabolites if 5 < len(metabolites[k]) < 11]) + summary_data["features_10_to_20"] = len([1 for k in metabolites if 10 < len(metabolites[k]) < 21]) + summary_data["features_gt20"] = len([1 for k in metabolites if 20 < len(metabolites[k])]) + summary_data["pos_only"] = len([1 for k in metabolitesIonMode if metabolitesIonMode[k] == {"+"}]) + summary_data["neg_only"] = len([1 for k in metabolitesIonMode if metabolitesIonMode[k] == {"-"}]) + summary_data["both_modes"] = len([1 for k in metabolitesIonMode if {"+", "-"}.issubset(metabolitesIonMode[k])]) - features = set() - negMode = set() - posMode = set() - metabolites = {} - metabolitesIonMode = {} - - for row in table_df.iter_rows(named=True): - num = row["Num"] - features.add(num) - if row["Ionisation_Mode"] == "-": - negMode.add(num) - else: - posMode.add(num) - ogroup = row["OGroup"] - if ogroup not in metabolites: - metabolites[ogroup] = [] - metabolites[ogroup].append(num) - if ogroup not in metabolitesIonMode: - metabolitesIonMode[ogroup] = set() - metabolitesIonMode[ogroup].add(row["Ionisation_Mode"]) - - texts.append(" Features %12d\n Metabolites %12d\n" % (len(features), len(metabolites))) - texts.append("\n") - texts.append(" %12d metabolites with a single feature\n" % (len([1 for key in metabolites.keys() if len(metabolites[key]) == 1]))) - texts.append(" %12d metabolites with two features\n" % (len([1 for key in metabolites.keys() if len(metabolites[key]) == 2]))) - texts.append(" %12d metabolites with three features\n" % (len([1 for key in metabolites.keys() if len(metabolites[key]) == 3]))) - texts.append(" %12d metabolites with four features\n" % (len([1 for key in metabolites.keys() if len(metabolites[key]) == 4]))) - texts.append(" %12d metabolites with five features\n" % (len([1 for key in metabolites.keys() if len(metabolites[key]) == 5]))) - texts.append(" %12d metabolites with >5 and <11 features\n" % (len([1 for key in metabolites.keys() if 5 < len(metabolites[key]) < 11]))) - texts.append(" %12d metabolites with >10 and <21 features\n" % (len([1 for key in metabolites.keys() if 10 < len(metabolites[key]) < 21]))) - texts.append(" %12d metabolites with >20 features\n" % (len([1 for key in metabolites.keys() if 20 < len(metabolites[key])]))) - texts.append("\n") - texts.append(" %12d metabolites with only ions in the positive ionization mode\n" % (len([1 for key in metabolitesIonMode.keys() if metabolitesIonMode[key] == {"+"}]))) - texts.append(" %12d metabolites with only ions in the negative ionization mode\n" % (len([1 for key in metabolitesIonMode.keys() if metabolitesIonMode[key] == {"-"}]))) - texts.append(" %12d metabolites with ions in both ionization modes\n" % (len([1 for key in metabolitesIonMode.keys() if {"+", "-"}.issubset(metabolitesIonMode[key])]))) - texts.append("\n") - texts.append("\n") except Exception as ex: import traceback as _tb - texts.append(f" Error reading convoluted results: {ex}\n") - texts.append(f" {_tb.format_exc()}\n\n") + logging.warning(f"Error reading convoluted results for summary: {ex}\n{_tb.format_exc()}") - resFileFull = str(self.ui.groupsSave.text()) - if os.path.exists(resFileFull): - texts.append("Omitted features\n-=-=-=-=-=-=-=-=-\n\n") try: - res_db = PolarsDB(resFileFull, format="xlsx", load_all_tables=True) + res_db2 = PolarsDB(resFileFull, format="xlsx", load_all_tables=True) omitted_sheet = "2_StatColumns_Omitted" - if omitted_sheet in res_db.tables: - omitted_df = res_db.tables[omitted_sheet] - features = set(omitted_df["Num"].to_list()) - texts.append(" Features %12d\n" % len(features)) - else: - texts.append(" No omitted features found\n") + if omitted_sheet in res_db2.tables: + omitted_df = res_db2.tables[omitted_sheet] + summary_data["omitted_features"] = len(set(omitted_df["Num"].to_list())) except Exception as ex: - import traceback as _tb + logging.warning(f"Error reading omitted features for summary: {ex}") - texts.append(f" Error reading omitted features: {ex}\n") - texts.append(f" {_tb.format_exc()}\n\n") - - # logging.info("".join(texts)) - - pw = QScrollableMessageBox( - parent=None, - text="".join(texts), - title="Processing results", - width=700, - height=700, - ) - pw.exec() + dlg = ResultsSummaryDialog(parent=self, file_rows=file_rows, summary_data=summary_data) + dlg.exec() def groupFilesChanges(self, sta): self.ui.label_26.setEnabled(sta) @@ -6585,24 +6514,42 @@ def selectedResChanged(self): ) if item.myType == "Features": - mzs = [] - rts = [] + _fm_rts = [] + _fm_mzs = [] + _fm_areas = [] plotTypes.add("Features") - for i in range(item.childCount()): - child = item.child(i) - assert child.myType == "feature" - mzs.append(child.myData.mz) - rts.append(child.myData.NPeakCenterMin / 60.0) - self.drawPlot( - self.ui.pl1, - plotIndex=0, - x=rts, - y=mzs, - ylab="m/z", - useCol=0, - scatter=True, - plot=False, + plotTypes.add("FeatureMap") + for _fmi in range(item.childCount()): + _fmc = item.child(_fmi) + assert _fmc.myType == "feature" + _fm_rts.append(_fmc.myData.NPeakCenterMin / 60.0) + _fm_mzs.append(_fmc.myData.mz) + try: + _fm_areas.append(max(1.0, float(_fmc.text(7).split(" / ")[0]))) + except Exception: + _fm_areas.append(1.0) + if _fm_areas: + import math as _math + + _log_areas = [_math.log10(a) for a in _fm_areas] + _min_log = min(_log_areas) + _max_log = max(_log_areas) + _range_log = max(_max_log - _min_log, 1.0) + _fm_sizes = [20 + 200 * (la - _min_log) / _range_log for la in _log_areas] + else: + _fm_sizes = [] + _ax_fm = self.ui.pl1.twinxs[0] + _ax_fm.scatter( + _fm_rts, + _fm_mzs, + s=_fm_sizes, + c=[predefinedColors[0]] * len(_fm_rts), + alpha=0.6, ) + _ax_fm.set_xlabel("Retention time (min)") + _ax_fm.set_ylabel("m/z") + _ax_fm.set_title(f"Feature map ({len(_fm_rts)} feature pairs)") + mzs = _fm_mzs if item.myType == "feature": cp = item.myData @@ -6992,27 +6939,54 @@ def selectedResChanged(self): item.setBackground(7, QColor(predefinedColors[(useColi) % len(predefinedColors)])) if item.myType == "Feature Groups": + import math as _math + plotTypes.add("Feature Groups") - for i in range(item.childCount()): - child = item.child(i) - mzs = [] - rts = [] - for j in range(child.childCount()): - feature = child.child(j) - assert feature.myType == "feature" - - mzs.append(feature.myData.mz) - rts.append(feature.myData.NPeakCenterMin / 60.0) - self.drawPlot( - self.ui.pl1, - plotIndex=0, - x=rts, - y=mzs, - ylab="m/z", - useCol=i, - scatter=True, - plot=True, - ) + plotTypes.add("FeatureMap") + _all_fm_rts = [] + _all_fm_mzs = [] + _all_fm_sizes = [] + _all_fm_colors = [] + for _gi in range(item.childCount()): + _gc = item.child(_gi) + _g_rts = [] + _g_mzs = [] + _g_areas = [] + for _gfi in range(_gc.childCount()): + _gff = _gc.child(_gfi) + assert _gff.myType == "feature" + _g_rts.append(_gff.myData.NPeakCenterMin / 60.0) + _g_mzs.append(_gff.myData.mz) + try: + _g_areas.append(max(1.0, float(_gff.text(7).split(" / ")[0]))) + except Exception: + _g_areas.append(1.0) + if _g_areas: + _g_log = [_math.log10(a) for a in _g_areas] + _g_min = min(_g_log) + _g_max = max(_g_log) + _g_range = max(_g_max - _g_min, 1.0) + _g_sizes = [20 + 200 * (la - _g_min) / _g_range for la in _g_log] + else: + _g_sizes = [] + _col = predefinedColors[_gi % len(predefinedColors)] + _all_fm_rts.extend(_g_rts) + _all_fm_mzs.extend(_g_mzs) + _all_fm_sizes.extend(_g_sizes) + _all_fm_colors.extend([_col] * len(_g_rts)) + _ax_fg = self.ui.pl1.twinxs[0] + _ax_fg.scatter( + _all_fm_rts, + _all_fm_mzs, + s=_all_fm_sizes, + c=_all_fm_colors, + alpha=0.6, + ) + _ax_fg.set_xlabel("Retention time (min)") + _ax_fg.set_ylabel("m/z") + _n_groups = item.childCount() + _n_feat = len(_all_fm_rts) + _ax_fg.set_title(f"Feature map ({_n_feat} feature pairs in {_n_groups} metabolite groups)") if item.myType == "featureGroup": selFeatureGroups.append(item) @@ -8083,6 +8057,9 @@ def selectedResChanged(self): # self.drawPlot(self.ui.pl3, plotIndex=0, x=toDrawMzs, y=toDrawInts, useCol="lightgrey", multipleLocator=None, alpha=0.1, title="", xlab="MZ") + if not toDrawMzs: + continue + bm = ( min( range(len(toDrawMzs)), @@ -8884,18 +8861,22 @@ def selectedResChanged(self): # elif "Features" in plotTypes or "Feature Groups" in plotTypes or "feature" in plotTypes: - if self.ui.scaleFeatures.checkState() == QtCore.Qt.Checked: - self.ui.pl1.axes.set_ylabel("Intensity [counts; normalised]") - else: - self.ui.pl1.axes.set_ylabel("Intensity [counts]") - if self.ui.autoZoomPlot.checkState() == QtCore.Qt.Checked: - self.drawCanvas( - self.ui.pl1, - ylim=(minIntY * 1.6, maxIntY * 1.6), - xlim=(minTime * 0.85, maxTime * 1.15), - ) - else: + if "FeatureMap" in plotTypes and "feature" not in plotTypes: + # Top-level feature map: axes already labelled; just draw canvas self.drawCanvas(self.ui.pl1) + else: + if self.ui.scaleFeatures.checkState() == QtCore.Qt.Checked: + self.ui.pl1.axes.set_ylabel("Intensity [counts; normalised]") + else: + self.ui.pl1.axes.set_ylabel("Intensity [counts]") + if self.ui.autoZoomPlot.checkState() == QtCore.Qt.Checked: + self.drawCanvas( + self.ui.pl1, + ylim=(minIntY * 1.6, maxIntY * 1.6), + xlim=(minTime * 0.85, maxTime * 1.15), + ) + else: + self.drawCanvas(self.ui.pl1) # # @@ -13200,7 +13181,7 @@ def main(): + f"If importing mzML files results in the error
of missing files, please find the correct version at
{OBO_DOWNLOAD_URL}.
" + "Please download the corresponding obo-file and
save it to the folder in the error message.
" + "You can also open this page via the menu
('Tools'->'Download OBO files').
" - + "
" + + "
" + "To generate a template for a database, select
'Download Database Template' from the 'Tools' menu.", QtWidgets.QMessageBox.Ok, ) diff --git a/src/findIsoPairs_matchPartners.py b/src/findIsoPairs_matchPartners.py index a45951e..d132034 100644 --- a/src/findIsoPairs_matchPartners.py +++ b/src/findIsoPairs_matchPartners.py @@ -17,19 +17,26 @@ from copy import deepcopy +import traceback from .formulaTools import formulaTools from .utils import Bunch, getNormRatio maxSub = 5 -def getSubstitutionArray(purity, xMax, maxSub): +def calculate_theoretical_ratios(isotopic_enrichment, Xn_max, maxSub): + ''' + calculates the theoretical isotopolog distribution for a given isotopic enrichment and number of atoms + isotopic_enrichment: isotopic enrichment of the isotope element (e.g. 0.9893 for 12C) + Xn_max: maximum number of atoms that can be exchanged (e.g. 3 for 3 carbon atoms) + maxSub: maximum number of substitutions + ''' ret = [] - ret.append([-1 for n in range(0, maxSub + 1)]) - for i in range(1, xMax + 1): + ret.append([]) + for Xn in range(1, Xn_max + 1): cur = [] - for j in range(0, maxSub + 1): - cur.append(getNormRatio(purity, i, j)) + for cur_exchange_number in range(0, min(Xn, maxSub) + 1): + cur.append(getNormRatio(isotopic_enrichment, Xn, cur_exchange_number)) ret.append(cur) return ret @@ -144,14 +151,13 @@ def matchPartners( reportFunction=None, writeExtendedDiagnostics=True, ): - scanRTRange = stopTime - startTime + scan_rt_range = stopTime - startTime - cValidationOffset = 1.00335484 # mass difference between 12C and 13C + c13c12_offset = 1.00335484 # mass difference between 12C and 13C + ori_Xoffset = xOffset - detectedIonPairs = [] - - oriXOffset = xOffset - oriCValidationOffset = cValidationOffset + detected_signal_pairs = [] + fT = formulaTools() if labellingElement == "C": useCIsotopePatternValidation = 0 @@ -159,135 +165,140 @@ def matchPartners( # when the labelling element is carbon (to some extend that's logical) # substitution arrays for checking the number of carbon atoms - purityNArray = getSubstitutionArray(purityN, max(xCounts) + 1, maxSub) # native metabolite - purityLArray = getSubstitutionArray(purityL, max(xCounts) + 1, maxSub) # labelled metabolite - - labelingElements = {} - labelingElements["C"] = Bunch( - nativeIsotope="12C", - labelingIsotope="13C", - massNative=12.0, - massLabeled=13.00335, - isotopicEnrichmentNative=0.9893, - isotopicEnrichmentLabeled=0.995, - minXn=1, - maxXn=3, - ) - labelingElements["H"] = Bunch( - nativeIsotope="1H", - labelingIsotope="2H", - massNative=1.0078250, - massLabeled=2.0141018, - isotopicEnrichmentNative=0.9999, - isotopicEnrichmentLabeled=0.96, - minXn=0, - maxXn=9, - ) - - ## combinations of labeling elements - tempCombs = getCombinationsOfLabel(["C", "H"], labelingElements, 2, 12) - combs = [] - for comb in tempCombs: - ## remove implausible labeling combinations - if True: - if "C" in comb.atoms.keys() and comb.atoms["C"] == 1 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3])): - combs.append(comb) - if "C" in comb.atoms.keys() and comb.atoms["C"] == 2 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3, 4, 5, 6])): - combs.append(comb) - if "C" in comb.atoms.keys() and comb.atoms["C"] == 3 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3, 4, 5, 6, 7, 8, 9])): - combs.append(comb) - - fT = formulaTools() + theoretical_ratios_native = calculate_theoretical_ratios(purityN, max(xCounts) + 1, maxSub) # native metabolite + theoretical_ratios_labeled = calculate_theoretical_ratios(purityL, max(xCounts) + 1, maxSub) # labelled metabolite + # beta-feature, support for two labeling elements at once (e.g., custom tracers) useDoubleLabelingCombinations = False - if useDoubleLabelingCombinations: - print("The following labeling configurations are used:") - for comb in combs: - print(comb) + labelingElements = {} + labelingElements["C"] = Bunch( + nativeIsotope="12C", + labelingIsotope="13C", + massNative=12.0, + massLabeled=13.00335, + isotopicEnrichmentNative=0.9893, + isotopicEnrichmentLabeled=0.995, + minXn=1, + maxXn=3, + ) + labelingElements["H"] = Bunch( + nativeIsotope="1H", + labelingIsotope="2H", + massNative=1.0078250, + massLabeled=2.0141018, + isotopicEnrichmentNative=0.9999, + isotopicEnrichmentLabeled=0.96, + minXn=0, + maxXn=9, + ) + + ## combinations of labeling elements + tempCombs = getCombinationsOfLabel(["C", "H"], labelingElements, 2, 12) + combs = [] + for comb in tempCombs: + ## remove implausible labeling combinations + if True: + if "C" in comb.atoms.keys() and comb.atoms["C"] == 1 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3])): + combs.append(comb) + if "C" in comb.atoms.keys() and comb.atoms["C"] == 2 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3, 4, 5, 6])): + combs.append(comb) + if "C" in comb.atoms.keys() and comb.atoms["C"] == 3 and ("H" not in comb.atoms.keys() or ("H" in comb.atoms.keys() and comb.atoms["H"] in [1, 2, 3, 4, 5, 6, 7, 8, 9])): + combs.append(comb) + if useDoubleLabelingCombinations: + print("The following labeling configurations are used:") + for comb in combs: + print(comb) + + # other parameters + max_other_iso_intensity_ratio = 0.25 # iterate over all MS scans (lvl. 1) - curScanIndex = 0 - for j in range(0, len(mzXMLData.MS1_list)): + cur_native_scan_index = 0 + next_signal_pair_id = 1 + for cur_scan_index in range(0, len(mzXMLData.MS1_list)): try: - curScan = mzXMLData.MS1_list[j] - curScanDetectedIonPairs = [] + cur_scan = mzXMLData.MS1_list[cur_scan_index] + cur_scan_detected_signal_pairs = [] # check for correct filterline and scan time - if curScan.filter_line == filterLine: - if startTime <= (curScan.retention_time / 60.0) <= stopTime: + if cur_scan.filter_line == filterLine: + if startTime <= (cur_scan.retention_time / 60.0) <= stopTime: if reportFunction is not None: reportFunction( - (curScan.retention_time / 60.0 - startTime) / scanRTRange, - "RT %.2f, found patterns: %d" % (curScan.retention_time / 60.0, len(detectedIonPairs)), + (cur_scan.retention_time / 60.0 - startTime) / scan_rt_range, + "RT %.2f, found patterns: %d" % (cur_scan.retention_time / 60.0, len(detected_signal_pairs)), ) + # Compute the scan used to search for labeled signals; may be offset relative to the native scan. # Falls back to curScan when the offset puts the index out of bounds or on a different filter line. # but make sure that the offset is scanIndexOffset scans of the correct filter_line - cj = j - cur_scan_Index_Offset = 0 - direction = 1 if scanIndexOffset >= 0 else -1 - while cj > 0: - if mzXMLData.MS1_list[cj].filter_line == filterLine: - if cur_scan_Index_Offset == scanIndexOffset: - break - else: - cur_scan_Index_Offset += direction - cj += direction - - labScanJ = cj + lab_scan_index = cur_scan_index + if scanIndexOffset != 0: + cur_scan_Index_Offset = 0 + direction = 1 if scanIndexOffset >= 0 else -1 + while lab_scan_index > 0: + if mzXMLData.MS1_list[lab_scan_index].filter_line == filterLine: + if cur_scan_Index_Offset == scanIndexOffset: + break + else: + cur_scan_Index_Offset += direction + lab_scan_index += direction + + # use labeled scan, test if it is valid (correct filter line, within bounds) + labScanJ = lab_scan_index if 0 <= labScanJ < len(mzXMLData.MS1_list) and mzXMLData.MS1_list[labScanJ].filter_line == filterLine: labScan = mzXMLData.MS1_list[labScanJ] else: - raise RuntimeError(f"ERROR: Could not find a suitable lab scan for scan index {j} with offset {scanIndexOffset}. ") + raise RuntimeError(f"ERROR: Could not find a suitable lab scan for scan index {cur_scan_index} with offset {scanIndexOffset}. ") dontUsePeakIndices = [] # assume each peak to be a valid M (monoisotopic, native metabolite ion) # and verify this assumption (search for (partially) labelled pendant) - for currentPeakIndex in range(0, len(curScan.mz_list)): + for currentPeakIndex in range(0, len(cur_scan.mz_list)): if currentPeakIndex not in dontUsePeakIndices: - curPeakmz = curScan.mz_list[currentPeakIndex] - curPeakIntensity = curScan.intensity_list[currentPeakIndex] + isoM_mz = cur_scan.mz_list[currentPeakIndex] + isoM_int = cur_scan.intensity_list[currentPeakIndex] - curPeakDetectedIonPairs = [] + cur_signal_detected_partners = [] # only consider peaks above the threshold - if curPeakIntensity >= intensityThres: - skipOtherLoadings = False + if isoM_int >= intensityThres: + skip_other_loadings = False ## do not process peaks that are likely isotopologs - backIsos = [] - for l in range(maxLoading, 0, -1): - iso = curScan.findMZ(curPeakmz - oriCValidationOffset / l, ppm) - iso = curScan.getMostIntensePeak(iso[0], iso[1]) - - if iso != -1 and curScan.intensity_list[iso] > curPeakIntensity: - backIsos.append(l) - if len(backIsos) > 0: + m_negative_C_isotopologs = [] + for current_charge in range(maxLoading, 0, -1): + iso = cur_scan.findMZ(isoM_mz - c13c12_offset / current_charge, ppm) + iso = cur_scan.getMostIntensePeak(iso[0], iso[1]) + + if iso != -1 and cur_scan.intensity_list[iso] > isoM_int * max_other_iso_intensity_ratio: + m_negative_C_isotopologs.append(current_charge) + if len(m_negative_C_isotopologs) > 0: continue - possibleLoadings = [] + possible_charges = [] ## figure out possible loadings - for l in range(maxLoading, 0, -1): - iso = curScan.findMZ( - curPeakmz + oriCValidationOffset / l, + for current_charge in range(maxLoading, 0, -1): + iso = cur_scan.findMZ( + isoM_mz + c13c12_offset / current_charge, ppm, start=currentPeakIndex, ) - iso = curScan.getMostIntensePeak(iso[0], iso[1]) + iso = cur_scan.getMostIntensePeak(iso[0], iso[1]) if iso != -1: - possibleLoadings.append(l) + possible_charges.append(current_charge) break ## skip other loadings - if len(possibleLoadings) == 0: - possibleLoadings = [1] + if len(possible_charges) == 0: + possible_charges = [1] - for curLoading in possibleLoadings: - if not skipOtherLoadings: - xOffset = oriXOffset / float(curLoading) - cValidationOffset = oriCValidationOffset / float(curLoading) + for current_charge in possible_charges: + if not skip_other_loadings: + xOffset_charged = ori_Xoffset / float(current_charge) + c13c12_offset_charged = c13c12_offset / float(current_charge) # C-isotope distribution validation for labelling with N, S, ... (useCValidation == 2) # The carbon distribution of both isotopologs is checked for equality @@ -301,63 +312,63 @@ def matchPartners( # EXPERIMENTAL: has not been tested with real data (not N or S labelled sample material # was available) if not useDoubleLabelingCombinations and useCIsotopePatternValidation != 0: - isoM_m1 = curScan.findMZ(curPeakmz - cValidationOffset, ppm * 2) - isoM_m1 = curScan.getMostIntensePeak(isoM_m1[0], isoM_m1[1]) - if isoM_m1 != -1: - obRatio = curScan.intensity_list[isoM_m1] / curPeakIntensity - if obRatio >= 0.5: + isoM_m1_index = cur_scan.findMZ(isoM_mz - c13c12_offset_charged, ppm * 2) + isoM_m1_index = cur_scan.getMostIntensePeak(isoM_m1_index[0], isoM_m1_index[1]) + if isoM_m1_index != -1: + obs_ratio_Mm1_to_M = cur_scan.intensity_list[isoM_m1_index] / isoM_int + if obs_ratio_Mm1_to_M >= 0.5: continue # When lowAbundanceIsotopeCutoff is enabled, also check M-1 abundance # to verify the detected peak is truly M and not an isotopolog artifact - if lowAbundanceIsotopeCutoff and isoM_m1 != -1: - isoM_m1_Intensity = curScan.intensity_list[isoM_m1] - if isoM_m1_Intensity > curPeakIntensity: + if lowAbundanceIsotopeCutoff and isoM_m1_index != -1: + isoM_m1_Intensity = cur_scan.intensity_list[isoM_m1_index] + if isoM_m1_Intensity > isoM_int: # M-1 is more intense than M: likely not the monoisotopic peak continue # find M+1 peak - isoM_p1 = curScan.findMZ( - curPeakmz + cValidationOffset, + isoM_p1_index = cur_scan.findMZ( + isoM_mz + c13c12_offset_charged, ppm, start=currentPeakIndex, ) - isoM_p1 = curScan.getMostIntensePeak(isoM_p1[0], isoM_p1[1]) - if isoM_p1 != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff: + isoM_p1_index = cur_scan.getMostIntensePeak(isoM_p1_index[0], isoM_p1_index[1]) + if isoM_p1_index != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff: # test certain number of labelled carbon atoms - for xCount in sorted(xCounts, reverse=True): + for current_Cn in sorted(xCounts, reverse=True): # find corresponding M' peak (search in labScan, which may be offset) - isoM_pX = labScan.findMZ( - curPeakmz + xCount * xOffset, + isoMP_index = labScan.findMZ( + isoM_mz + current_Cn * c13c12_offset_charged, ppm, ) - isoM_pX = labScan.getMostIntensePeak( - isoM_pX[0], - isoM_pX[1], + isoMP_index = labScan.getMostIntensePeak( + isoMP_index[0], + isoMP_index[1], intensityThres, ) - if isoM_pX != -1: - labPeakmz = labScan.mz_list[isoM_pX] - labPeakIntensity = labScan.intensity_list[isoM_pX] + if isoMP_index != -1: + isoMP_mz = labScan.mz_list[isoMP_index] + isoMP_int = labScan.intensity_list[isoMP_index] # find M'-1 peak - isoM_pXm1 = labScan.findMZ( - curPeakmz + xCount * xOffset - cValidationOffset, + isoMP_m1_index = labScan.findMZ( + isoM_mz + current_Cn * c13c12_offset_charged - c13c12_offset_charged, ppm * 2, ) - isoM_pXm1 = labScan.getMostIntensePeak( - isoM_pXm1[0], - isoM_pXm1[1], + isoMP_m1_index = labScan.getMostIntensePeak( + isoMP_m1_index[0], + isoMP_m1_index[1], ) - if isoM_pXm1 != -1: - obRatio = labScan.intensity_list[isoM_pXm1] / labScan.intensity_list[isoM_pX] - if obRatio >= 0.5: + if isoMP_m1_index != -1: + obs_ratio_Mm1_to_M = labScan.intensity_list[isoMP_m1_index] / labScan.intensity_list[isoMP_index] + if obs_ratio_Mm1_to_M >= 0.5: continue # (1.) check if M' and M ratio are as expected if checkRatio: - rat = curPeakIntensity / labPeakIntensity + rat = isoM_int / isoMP_int if minRatio <= rat <= maxRatio: pass ## ratio check passed else: @@ -365,93 +376,93 @@ def matchPartners( ## no isotopolog verification needs to be performed if peakCountLeft == 1 and peakCountRight == 1: - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labPeakmz, - tmz=xCount * xOffset, - xCount=xCount, - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + id=len(cur_signal_detected_partners), + mz=isoM_mz, + lmz=isoMP_mz, + tmz=current_Cn * c13c12_offset_charged, + xCount=current_Cn, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - skipOtherLoadings = True + skip_other_loadings = True continue # find M'+1 peak - isoM_pXp1 = labScan.findMZ( - curPeakmz + xCount * xOffset + cValidationOffset, + isoMP_p1_index = labScan.findMZ( + isoM_mz + current_Cn * c13c12_offset_charged + c13c12_offset_charged, ppm, ) - isoM_pXp1 = labScan.getMostIntensePeak( - isoM_pXp1[0], - isoM_pXp1[1], + isoMP_p1_index = labScan.getMostIntensePeak( + isoMP_p1_index[0], + isoMP_p1_index[1], ) # calculate the ratio of M+1/M (native, always in curScan) - isoPeakIntensity = curScan.intensity_list[isoM_p1] + isoPeakIntensity = cur_scan.intensity_list[isoM_p1_index] if peakCountLeft == 1: ratioN = None elif peakCountLeft > 1 and lowAbundanceIsotopeCutoff and isoPeakIntensity <= isotopologIntensityThres: ratioN = None else: - ratioN = isoPeakIntensity / curPeakIntensity + ratioN = isoPeakIntensity / isoM_int # calculate the ratio of M'+1/M' (labeled, in labScan) - isoLabPeakIntensity = labScan.intensity_list[isoM_pXp1] + isoLabPeakIntensity = labScan.intensity_list[isoMP_p1_index] if peakCountRight == 1: ratioL = None elif peakCountRight > 1 and lowAbundanceIsotopeCutoff and isoLabPeakIntensity <= isotopologIntensityThres: ratioL = None else: - ratioL = isoLabPeakIntensity / labPeakIntensity + ratioL = isoLabPeakIntensity / isoMP_int # 2. check if the observed M'+1/M' ratio and the M+1/M ratio are approximately equal if (ratioN is not None and ratioL is not None) and abs(ratioN - ratioL) <= intensityErrorL: - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labPeakmz, - tmz=xCount * xOffset, - xCount=xCount, - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + id=len(cur_signal_detected_partners), + mz=isoM_mz, + lmz=isoMP_mz, + tmz=current_Cn * c13c12_offset_charged, + xCount=current_Cn, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - skipOtherLoadings = True + skip_other_loadings = True continue elif lowAbundanceIsotopeCutoff and (ratioN is None or ratioL is None): - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labPeakmz, - tmz=xCount * xOffset, - xCount=xCount, - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + id=len(cur_signal_detected_partners), + mz=isoM_mz, + lmz=isoMP_mz, + tmz=current_Cn * c13c12_offset_charged, + xCount=current_Cn, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - skipOtherLoadings = True + skip_other_loadings = True continue # endregion @@ -468,63 +479,61 @@ def matchPartners( # region # (0.) verify that peak is M and not something else (e.g. M+1, M+1...) ## TODO improve me. Use seven golden rules or the number of carbon atoms - isoM_m1 = curScan.findMZ(curPeakmz - cValidationOffset, ppm) - isoM_m1 = curScan.getMostIntensePeak(isoM_m1[0], isoM_m1[1]) - if isoM_m1 != -1: - obRatio = curScan.intensity_list[isoM_m1] / curPeakIntensity - if obRatio >= 0.5: + isoM_m1_index = cur_scan.findMZ(isoM_mz - c13c12_offset_charged, ppm) + isoM_m1_index = cur_scan.getMostIntensePeak(isoM_m1_index[0], isoM_m1_index[1]) + if isoM_m1_index != -1: + obs_ratio_Mm1_to_M = cur_scan.intensity_list[isoM_m1_index] / isoM_int + if obs_ratio_Mm1_to_M >= max_other_iso_intensity_ratio: continue # find M+1 peak - isoM_p1 = curScan.findMZ( - curPeakmz + cValidationOffset, + isoM_p1_index = cur_scan.findMZ( + isoM_mz + c13c12_offset_charged, ppm, start=currentPeakIndex, ) - isoM_p1 = curScan.getMostIntensePeak(isoM_p1[0], isoM_p1[1]) - if isoM_p1 != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff: + isoM_p1_index = cur_scan.getMostIntensePeak(isoM_p1_index[0], isoM_p1_index[1]) + if isoM_p1_index != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff: # test certain number of labelled carbon atoms - for xCount in sorted(xCounts, reverse=True): + for current_Cn in sorted(xCounts, reverse=True): # stop for impossible carbon atom number - if xCount > curPeakmz * curLoading / 12: - continue # find corresponding M' peak (search in labScan, which may be offset) - isoM_pX = labScan.findMZ( - curPeakmz + xCount * cValidationOffset, + isoMP_index = labScan.findMZ( + isoM_mz + current_Cn * c13c12_offset_charged, ppm, ) - isoM_pX = labScan.getMostIntensePeak( - isoM_pX[0], - isoM_pX[1], + isoMP_index = labScan.getMostIntensePeak( + isoMP_index[0], + isoMP_index[1], intensityThres, ) - if isoM_pX != -1: - labPeakmz = labScan.mz_list[isoM_pX] - labPeakIntensity = labScan.intensity_list[isoM_pX] + if isoMP_index != -1: + isoMP_mz = labScan.mz_list[isoMP_index] + isoMP_int = labScan.intensity_list[isoMP_index] # (0.) verify that peak is M' and not something else (e.g. M'-1, M'-2) # only for AllExtract experiments - adjRatio = 0 - isoM_pXp1 = labScan.findMZ( - curPeakmz + (xCount + 1) * cValidationOffset, + obs_ratio_MPp1_to_MP = 0 + isoMP_p1_index = labScan.findMZ( + isoM_mz + (current_Cn + 1) * c13c12_offset_charged, ppm, ) - isoM_pXp1 = labScan.getMostIntensePeak( - isoM_pXp1[0], - isoM_pXp1[1], + isoMP_p1_index = labScan.getMostIntensePeak( + isoMP_p1_index[0], + isoMP_p1_index[1], ) - if isoM_pXp1 != -1: - adjRatio = labScan.intensity_list[isoM_pXp1] / labPeakIntensity + if isoMP_p1_index != -1: + obs_ratio_MPp1_to_MP = labScan.intensity_list[isoMP_p1_index] / isoMP_int if not metabolisationExperiment: - if adjRatio >= 0.5: + if obs_ratio_MPp1_to_MP >= max_other_iso_intensity_ratio: continue # (1.) check if M' and M ratio are as expected if checkRatio: - rat = curPeakIntensity / labPeakIntensity + rat = isoM_int / isoMP_int if minRatio <= rat <= maxRatio: pass ## ratio check passed else: @@ -532,52 +541,52 @@ def matchPartners( ## no isotopolog verification needs to be performed if peakCountLeft == 1 and peakCountRight == 1: - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labPeakmz, - tmz=xCount * cValidationOffset, - xCount=xCount, - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + id=next_signal_pair_id, + mz=isoM_mz, + lmz=isoMP_mz, + tmz=isoMP_mz - isoM_mz, + xCount=current_Cn, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - - skipOtherLoadings = True + next_signal_pair_id += 1 + skip_other_loadings = True continue # find M'-1 peak - isoM_pXm1 = labScan.findMZ( - curPeakmz + (xCount - 1) * cValidationOffset, + isoMP_m1_index = labScan.findMZ( + isoM_mz + (current_Cn - 1) * c13c12_offset_charged, ppm, ) - isoM_pXm1 = labScan.getMostIntensePeak( - isoM_pXm1[0], - isoM_pXm1[1], + isoMP_m1_index = labScan.getMostIntensePeak( + isoMP_m1_index[0], + isoMP_m1_index[1], ) - normRatioL = purityLArray[xCount][1] - normRatioN = purityNArray[xCount][1] + theoretical_ratio_native = theoretical_ratios_native[current_Cn][1] + theoretical_ratio_labeled = theoretical_ratios_labeled[current_Cn][1] # 2. check if the observed M'-1/M' ratio fits the theoretical one if peakCountRight == 1: pass - elif isoM_pXm1 == -1: - if lowAbundanceIsotopeCutoff and labPeakIntensity * normRatioL <= isotopologIntensityThres: + elif isoMP_m1_index == -1: + if lowAbundanceIsotopeCutoff and isoMP_int * theoretical_ratio_labeled <= isotopologIntensityThres: pass else: continue - elif isoM_pXm1 != -1: - isoM_pXm1_Intensity = labScan.intensity_list[isoM_pXm1] - observedRatioMp = isoM_pXm1_Intensity / labPeakIntensity - if abs(normRatioL - observedRatioMp) <= intensityErrorL: + elif isoMP_m1_index != -1: + isoMP_m1_int = labScan.intensity_list[isoMP_m1_index] + observed_ratio = isoMP_m1_int / isoMP_int + if abs(theoretical_ratio_labeled - observed_ratio) <= intensityErrorL: pass - elif lowAbundanceIsotopeCutoff and isoM_pXm1_Intensity <= isotopologIntensityThres: + elif lowAbundanceIsotopeCutoff and isoMP_m1_int <= isotopologIntensityThres: pass else: continue @@ -587,17 +596,17 @@ def matchPartners( # 3. check if the observed M+1/M ratio fits the theoretical one (native, always in curScan) if peakCountLeft == 1: pass - elif isoM_p1 == -1: - if lowAbundanceIsotopeCutoff and curPeakIntensity * (normRatioN + adjRatio) <= isotopologIntensityThres: + elif isoM_p1_index == -1: + if lowAbundanceIsotopeCutoff and isoM_int * (theoretical_ratio_native + obs_ratio_MPp1_to_MP) <= isotopologIntensityThres: pass else: continue - elif isoM_p1 != -1: - isoM_p1_Intensity = curScan.intensity_list[isoM_p1] - observedRatioM = isoM_p1_Intensity / curPeakIntensity - if abs((normRatioN + adjRatio) - observedRatioM) <= intensityErrorN: + elif isoM_p1_index != -1: + isoM_p1_int = cur_scan.intensity_list[isoM_p1_index] + observed_ratio = isoM_p1_int / isoM_int + if abs(theoretical_ratio_native + obs_ratio_MPp1_to_MP - observed_ratio) <= intensityErrorN: pass - elif lowAbundanceIsotopeCutoff and isoM_p1_Intensity <= isotopologIntensityThres: + elif lowAbundanceIsotopeCutoff and isoM_p1_int <= isotopologIntensityThres: pass else: continue @@ -606,23 +615,24 @@ def matchPartners( # All verification criteria are passed, store the ion signal pair # for further processing - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labPeakmz, - tmz=xCount * cValidationOffset, - xCount=xCount, - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + id=next_signal_pair_id, + mz=isoM_mz, + lmz=isoMP_mz, + tmz=isoMP_mz - isoM_mz, + xCount=current_Cn, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - skipOtherLoadings = True + next_signal_pair_id += 1 + skip_other_loadings = True # endregion # labeling patterns derived from one or more labeling-elements (e.g. 13C and D) @@ -635,65 +645,65 @@ def matchPartners( # NOTE: The option must be activated and the other two options must be deactivated if useDoubleLabelingCombinations: # find M+1 peak - isoM_p1 = curScan.findMZ( - curPeakmz + cValidationOffset / curLoading, + isoM_p1_index = cur_scan.findMZ( + isoM_mz + c13c12_offset / current_charge, ppm, start=currentPeakIndex, ) - isoM_p1 = curScan.getMostIntensePeak(isoM_p1[0], isoM_p1[1]) + isoM_p1_index = cur_scan.getMostIntensePeak(isoM_p1_index[0], isoM_p1_index[1]) intIsoM_p1 = 0 - if isoM_p1 != -1: - intIsoM_p1 = curScan.intensity_list[isoM_p1] + if isoM_p1_index != -1: + intIsoM_p1 = cur_scan.intensity_list[isoM_p1_index] - isoM_m1 = curScan.findMZ( - curPeakmz - cValidationOffset / curLoading, + isoM_m1_index = cur_scan.findMZ( + isoM_mz - c13c12_offset / current_charge, ppm, start=currentPeakIndex, ) - isoM_m1 = curScan.getMostIntensePeak(isoM_m1[0], isoM_m1[1]) + isoM_m1_index = cur_scan.getMostIntensePeak(isoM_m1_index[0], isoM_m1_index[1]) intIsoM_m1 = 0 - if isoM_m1 != -1: - intIsoM_m1 = curScan.intensity_list[isoM_m1] + if isoM_m1_index != -1: + intIsoM_m1 = cur_scan.intensity_list[isoM_m1_index] - if intIsoM_p1 < curPeakIntensity and intIsoM_m1 < curPeakIntensity and (isoM_p1 != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff): + if intIsoM_p1 < isoM_int and intIsoM_m1 < isoM_int and (isoM_p1_index != -1 or peakCountLeft == 1 or lowAbundanceIsotopeCutoff): # test certain number of labelled carbon atoms for comb in combs: # find corresponding M' peak (search in labScan, which may be offset) - isoM_pX = labScan.findMZ( - curPeakmz + comb.mzdelta / curLoading, + isoMP_index = labScan.findMZ( + isoM_mz + comb.mzdelta / current_charge, ppm, ) - isoM_pX = labScan.getMostIntensePeak( - isoM_pX[0], - isoM_pX[1], + isoMP_index = labScan.getMostIntensePeak( + isoMP_index[0], + isoMP_index[1], intensityThres, ) - if isoM_pX != -1: - labPeakmz = labScan.mz_list[isoM_pX] - labPeakIntensity = labScan.intensity_list[isoM_pX] + if isoMP_index != -1: + isoMP_mz = labScan.mz_list[isoMP_index] + isoMP_int = labScan.intensity_list[isoMP_index] # (1.) check if M' and M ratio are as expected if False: - rat = curPeakIntensity / labPeakIntensity + rat = isoM_int / isoMP_int if minRatio <= rat <= maxRatio: pass ## ratio check passed else: continue ## ratio check not passed # find M'-1 peak - isoM_pXm1 = labScan._findMZGeneric( - curPeakmz + (comb.mzdelta - 1.00628 * (1.0 + curPeakmz * ppm / 1000000)) / curLoading, - curPeakmz + (comb.mzdelta - 1.00335 * (1.0 - curPeakmz * ppm / 1000000)) / curLoading, + isoMP_m1_index = labScan._findMZGeneric( + isoM_mz + (comb.mzdelta - 1.00628 * (1.0 + isoM_mz * ppm / 1000000)) / current_charge, + isoM_mz + (comb.mzdelta - 1.00335 * (1.0 - isoM_mz * ppm / 1000000)) / current_charge, ) - isoM_pXm1 = labScan.getMostIntensePeak( - isoM_pXm1[0], - isoM_pXm1[1], + isoMP_m1_index = labScan.getMostIntensePeak( + isoMP_m1_index[0], + isoMP_m1_index[1], ) - if isoM_pXm1 != -1: - isoPeakIntensity = labScan.intensity_list[isoM_pXm1] - rat = isoPeakIntensity / labPeakIntensity + if isoMP_m1_index != -1: + isoPeakIntensity = labScan.intensity_list[isoMP_m1_index] + rat = isoPeakIntensity / isoMP_int if rat <= 0.75: pass @@ -701,18 +711,18 @@ def matchPartners( continue # find M'+1 peak - isoM_pXp1 = labScan._findMZGeneric( - curPeakmz + (comb.mzdelta + 1.00335 * (1.0 - curPeakmz * ppm / 1000000)) / curLoading, - curPeakmz + (comb.mzdelta + 1.00628 * (1.0 + curPeakmz * ppm / 1000000)) / curLoading, + isoMP_p1_index = labScan._findMZGeneric( + isoM_mz + (comb.mzdelta + 1.00335 * (1.0 - isoM_mz * ppm / 1000000)) / current_charge, + isoM_mz + (comb.mzdelta + 1.00628 * (1.0 + isoM_mz * ppm / 1000000)) / current_charge, ) - isoM_pXp1 = labScan.getMostIntensePeak( - isoM_pXp1[0], - isoM_pXp1[1], + isoMP_p1_index = labScan.getMostIntensePeak( + isoMP_p1_index[0], + isoMP_p1_index[1], ) - if isoM_pXp1 != -1: - isoPeakIntensity = labScan.intensity_list[isoM_pXp1] - rat = isoPeakIntensity / labPeakIntensity + if isoMP_p1_index != -1: + isoPeakIntensity = labScan.intensity_list[isoMP_p1_index] + rat = isoPeakIntensity / isoMP_int if rat <= 0.9: pass @@ -721,65 +731,33 @@ def matchPartners( # All verification criteria are passed, store the ion signal pair # for further processing - curPeakDetectedIonPairs.append( + cur_signal_detected_partners.append( mzFeature( - id=len(curPeakDetectedIonPairs), - mz=curPeakmz, - lmz=labScan.mz_list[isoM_pX], - tmz=comb.mzdelta / curLoading, + id=len(cur_signal_detected_partners), + mz=isoM_mz, + lmz=labScan.mz_list[isoMP_index], + tmz=comb.mzdelta / current_charge, xCount=fT.flatToString(comb.atoms), - scanIndex=curScanIndex, - scanid=curScan.id, - scantime=curScan.retention_time, - loading=curLoading, - nIntensity=curPeakIntensity, - lIntensity=labPeakIntensity, + scanIndex=cur_native_scan_index, + scanid=cur_scan.id, + scantime=cur_scan.retention_time, + loading=current_charge, + nIntensity=isoM_int, + lIntensity=isoMP_int, ionMode=ionMode, ) ) - skipOtherLoadings = True + skip_other_loadings = True # endregion - if False: ## select best fit - if len(curPeakDetectedIonPairs) > 0: - bestFit = None - bestFitPPMDiff = 1000000000 - - ## TODO select best fit based on isotopic pattern (e.g. intensity) - - for mt in curPeakDetectedIonPairs: - if abs(mt.lmz - mt.mz - mt.xCount * 1.00335) * 1000000.0 / mt.mz < bestFitPPMDiff: - bestFit = mt - bestFitPPMDiff = abs(mt.lmz - mt.mz - mt.xCount * 1.00335) * 1000000.0 / mt.mz - - curScanDetectedIonPairs.append(bestFit) - else: ## use all peak pairs - curScanDetectedIonPairs.extend(curPeakDetectedIonPairs) - - if len(curScanDetectedIonPairs) > 0 and False: - from .utils import printObjectsAsTable - - print("\n") - print(curScan.retention_time / 60.0) - printObjectsAsTable( - curScanDetectedIonPairs, - attrs=[ - "mz", - "xCount", - "loading", - "nIntensity", - "lIntensity", - "ionMode", - ], - ) + cur_scan_detected_signal_pairs.extend(cur_signal_detected_partners) - detectedIonPairs.extend(curScanDetectedIonPairs) - curScanIndex += 1 + detected_signal_pairs.extend(cur_scan_detected_signal_pairs) + cur_native_scan_index += 1 except Exception: - import traceback - + print(f"Error in findIsoPairs_matchPartners for scan {cur_scan.id} (index {cur_native_scan_index}):") traceback.print_exc() - return detectedIonPairs + return detected_signal_pairs diff --git a/src/mePyGuis/PeakPickingSettingsDialog.py b/src/mePyGuis/PeakPickingSettingsDialog.py index ca82f57..2c83fc2 100644 --- a/src/mePyGuis/PeakPickingSettingsDialog.py +++ b/src/mePyGuis/PeakPickingSettingsDialog.py @@ -177,10 +177,13 @@ def _build_ui(self): self.btnAddEic.setToolTip("Add a new EIC definition row") self.btnRemoveEic = QtWidgets.QPushButton("Remove Selected") self.btnRemoveEic.setToolTip("Remove selected EIC definition(s)") + self.btnDuplicateEic = QtWidgets.QPushButton("Duplicate Selected") + self.btnDuplicateEic.setToolTip("Duplicate the selected EIC definition row") self.btnPickEic = QtWidgets.QPushButton("Pick Peaks") self.btnPickEic.setToolTip("Run peak picking on defined EICs and show results below") eic_btn_layout.addWidget(self.btnAddEic) eic_btn_layout.addWidget(self.btnRemoveEic) + eic_btn_layout.addWidget(self.btnDuplicateEic) eic_btn_layout.addStretch() eic_btn_layout.addWidget(self.btnPickEic) eic_layout.addLayout(eic_btn_layout) @@ -222,12 +225,13 @@ def _build_ui(self): self.btnDefaults.clicked.connect(self._restore_defaults) self.btnAddEic.clicked.connect(self._add_eic_row) self.btnRemoveEic.clicked.connect(self._remove_eic_rows) + self.btnDuplicateEic.clicked.connect(self._duplicate_eic_row) self.btnPickEic.clicked.connect(self._run_preview) def _resize_eic_columns(self, event): - """Keep EIC table column widths at 60/20/10/10 ratio.""" + """Keep EIC table column widths at 45/25/15/15 ratio (File column 25% narrower).""" total = self.eicTable.viewport().width() - ratios = [0.60, 0.20, 0.10, 0.10] + ratios = [0.45, 0.25, 0.15, 0.15] hdr = self.eicTable.horizontalHeader() for col, ratio in enumerate(ratios): hdr.resizeSection(col, max(1, int(total * ratio))) @@ -1019,6 +1023,72 @@ def _remove_eic_rows(self): for r in rows: self.eicTable.removeRow(r) + def _duplicate_eic_row(self): + """Duplicate the selected EIC row, inserting a copy directly below it.""" + selected_rows = sorted({idx.row() for idx in self.eicTable.selectedIndexes()}) + if not selected_rows: + return + # Duplicate the last selected row (insert after it) + src_row = selected_rows[-1] + insert_at = src_row + 1 + + # Read current widget values from the source row + fileW = self.eicTable.cellWidget(src_row, 0) + filterW = self.eicTable.cellWidget(src_row, 1) + mzW = self.eicTable.cellWidget(src_row, 2) + ppmW = self.eicTable.cellWidget(src_row, 3) + + src_file_idx = fileW.currentIndex() if fileW else 0 + src_filter_idx = filterW.currentIndex() if filterW else 0 + src_mz = mzW.value() if mzW else 100.0 + src_ppm = ppmW.value() if ppmW else 5.0 + + # Insert new row at position + self.eicTable.insertRow(insert_at) + + # File combo + newFileCombo = QtWidgets.QComboBox() + if self._sample_files: + for group_name, fpath in self._sample_files: + fname = os.path.basename(fpath) + newFileCombo.addItem(f"{group_name} \u2014 {fname}", userData=fpath) + else: + newFileCombo.addItem("(no files loaded)", userData=None) + newFileCombo.setCurrentIndex(src_file_idx) + self.eicTable.setCellWidget(insert_at, 0, newFileCombo) + + # Filter combo + newFilterCombo = QtWidgets.QComboBox() + all_filter_lines = [] + for pol_key in ("+", "-"): + for fl in self._scan_events.get(pol_key, []): + if fl and fl != "None": + all_filter_lines.append(fl) + if all_filter_lines: + for fl in all_filter_lines: + newFilterCombo.addItem(fl) + else: + newFilterCombo.addItem("(no filter lines)") + newFilterCombo.setCurrentIndex(src_filter_idx) + self.eicTable.setCellWidget(insert_at, 1, newFilterCombo) + + # m/z + newMzSpin = QtWidgets.QDoubleSpinBox() + newMzSpin.setRange(0, 99999.0) + newMzSpin.setDecimals(5) + newMzSpin.setValue(src_mz) + self.eicTable.setCellWidget(insert_at, 2, newMzSpin) + + # ppm + newPpmSpin = QtWidgets.QDoubleSpinBox() + newPpmSpin.setRange(0.1, 1000.0) + newPpmSpin.setDecimals(1) + newPpmSpin.setValue(src_ppm) + self.eicTable.setCellWidget(insert_at, 3, newPpmSpin) + + # Select the duplicated row + self.eicTable.selectRow(insert_at) + def _run_preview(self): """Attempt to load EICs and run peak picking for preview.""" # Gather EIC definitions diff --git a/src/mePyGuis/ResultsSummaryDialog.py b/src/mePyGuis/ResultsSummaryDialog.py new file mode 100644 index 0000000..a8d6b6a --- /dev/null +++ b/src/mePyGuis/ResultsSummaryDialog.py @@ -0,0 +1,278 @@ +""" +Results Summary Dialog for MetExtract II. + +Replaces the scrollable text overview with two structured tables: +- Table 1: Per-file rows (one row per file × ionMode), coloured by group, sortable. +- Table 2: Bracketing/convoluted results summary. +""" + +from PySide6 import QtCore, QtGui, QtWidgets + + +class ResultsSummaryDialog(QtWidgets.QDialog): + """Dialog showing a tabular overview of MetExtract II processing results.""" + + def __init__(self, parent=None, file_rows=None, summary_data=None): + """ + Parameters + ---------- + file_rows : list[dict] + Each dict has keys: + group_name, group_color, file, ion_mode, + n_mzs, n_mz_bins, n_features, n_metabolites, + mz_delta_mean, mz_delta_std, + avg_ratio_signals, avg_ratio_signals_std, + avg_ratio_area, avg_ratio_abundance, + avg_enrichment, avg_enrichment_std, + error (str or None) + summary_data : dict or None + Keys: + n_features, n_metabolites, + features_1, features_2, features_3, features_4, features_5, + features_5_to_10, features_10_to_20, features_gt20, + pos_only, neg_only, both_modes, + omitted_features (int or None) + """ + super().__init__(parent) + self.setWindowTitle("Processing Results Overview") + self.resize(1300, 700) + + self._file_rows = file_rows or [] + self._summary_data = summary_data or {} + + self._build_ui() + self._populate_file_table() + self._populate_summary_table() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _build_ui(self): + root = QtWidgets.QVBoxLayout(self) + + # ── Note label ──────────────────────────────────────────────── + note = QtWidgets.QLabel("Note: All values are based on the last data processing run and are influenced by the chosen parameters. Use only as a quick overview.") + note.setWordWrap(True) + root.addWidget(note) + + splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) + root.addWidget(splitter, stretch=1) + + # ── Table 1: per-file results ────────────────────────────────── + top_group = QtWidgets.QGroupBox("Results of individual files") + top_layout = QtWidgets.QVBoxLayout(top_group) + top_layout.setContentsMargins(4, 4, 4, 4) + + self.fileTable = QtWidgets.QTableWidget(0, 12) + self.fileTable.setHorizontalHeaderLabels( + [ + "Group", + "File", + "Ion mode", + "Signal pairs", + "MZ bins", + "Feature pairs", + "Metabolites", + "m/z Δ (ppm mean)", + "m/z Δ (ppm std)", + "M:M' ratio (signals)", + "M:M' ratio (area / abund.)", + "L-Enrichment (%)", + ] + ) + self.fileTable.setSortingEnabled(True) + self.fileTable.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.fileTable.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.fileTable.horizontalHeader().setStretchLastSection(True) + self.fileTable.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Interactive) + self.fileTable.verticalHeader().setVisible(False) + top_layout.addWidget(self.fileTable) + splitter.addWidget(top_group) + + # ── Table 2: convoluted / bracketing summary ─────────────────── + bottom_group = QtWidgets.QGroupBox("Bracketing / convoluted results summary") + bottom_layout = QtWidgets.QVBoxLayout(bottom_group) + bottom_layout.setContentsMargins(4, 4, 4, 4) + + self.summaryTable = QtWidgets.QTableWidget(0, 2) + self.summaryTable.setHorizontalHeaderLabels(["Metric", "Value"]) + self.summaryTable.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.summaryTable.horizontalHeader().setStretchLastSection(True) + self.summaryTable.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) + self.summaryTable.verticalHeader().setVisible(False) + bottom_layout.addWidget(self.summaryTable) + splitter.addWidget(bottom_group) + + splitter.setStretchFactor(0, 2) + splitter.setStretchFactor(1, 1) + + # ── Close button ─────────────────────────────────────────────── + btn_close = QtWidgets.QPushButton("Close") + btn_close.clicked.connect(self.accept) + btn_row = QtWidgets.QHBoxLayout() + btn_row.addStretch() + btn_row.addWidget(btn_close) + root.addLayout(btn_row) + + # ------------------------------------------------------------------ + # Population helpers + # ------------------------------------------------------------------ + + def _make_item(self, text, align=QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter): + item = QtWidgets.QTableWidgetItem(str(text)) + item.setTextAlignment(align) + item.setFlags(item.flags() & ~QtCore.Qt.ItemIsEditable) + return item + + def _make_num_item(self, value): + """Create a table item that sorts numerically.""" + if value is None: + item = QtWidgets.QTableWidgetItem("") + else: + item = QtWidgets.QTableWidgetItem() + item.setData(QtCore.Qt.DisplayRole, value) + item.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + item.setFlags(item.flags() & ~QtCore.Qt.ItemIsEditable) + return item + + def _populate_file_table(self): + self.fileTable.setSortingEnabled(False) + self.fileTable.setRowCount(len(self._file_rows)) + + # Track group → background colour so adjacent rows of the same group share the same tint + _group_colors = {} + + for row_idx, row in enumerate(self._file_rows): + group_name = row.get("group_name", "") + group_color = row.get("group_color", "") + + # Resolve background colour for this group row + bg_color = None + if group_color: + try: + qc = QtGui.QColor(group_color) + if qc.isValid(): + # Use a lighter tint so text remains legible + qc.setAlpha(60) + bg_color = qc + except Exception: + pass + + if bg_color is None and group_name: + # Assign a deterministic pastel from a fixed palette + if group_name not in _group_colors: + _pastel = [ + QtGui.QColor(200, 220, 255, 80), + QtGui.QColor(200, 255, 220, 80), + QtGui.QColor(255, 220, 200, 80), + QtGui.QColor(240, 200, 255, 80), + QtGui.QColor(255, 255, 200, 80), + QtGui.QColor(200, 255, 255, 80), + ] + _group_colors[group_name] = _pastel[len(_group_colors) % len(_pastel)] + bg_color = _group_colors[group_name] + + error = row.get("error") + + cells = [ + self._make_item(group_name), + self._make_item(row.get("file", "")), + self._make_item(row.get("ion_mode", "")), + ] + + if error: + cells.append(self._make_item(f"Error: {error}")) + # Fill remaining columns with empty items + while len(cells) < 12: + cells.append(self._make_item("")) + else: + n_mzs = row.get("n_mzs") + n_mz_bins = row.get("n_mz_bins") + n_features = row.get("n_features") + n_metabolites = row.get("n_metabolites") + mz_mean = row.get("mz_delta_mean") + mz_std = row.get("mz_delta_std") + ratio_sig = row.get("avg_ratio_signals") + ratio_sig_std = row.get("avg_ratio_signals_std") + ratio_area = row.get("avg_ratio_area") + ratio_abund = row.get("avg_ratio_abundance") + enr = row.get("avg_enrichment") + enr_std = row.get("avg_enrichment_std") + + cells.append(self._make_num_item(n_mzs)) + cells.append(self._make_num_item(n_mz_bins)) + cells.append(self._make_num_item(n_features)) + cells.append(self._make_num_item(n_metabolites)) + cells.append(self._make_item("%.2f" % mz_mean if mz_mean is not None else "")) + cells.append(self._make_item("%.2f" % mz_std if mz_std is not None else "")) + cells.append(self._make_item("%.2f ± %.2f" % (ratio_sig, ratio_sig_std) if ratio_sig is not None else "")) + cells.append( + self._make_item( + "%s / %s" + % ( + "%.2f" % ratio_area if ratio_area is not None else "-", + "%.2f" % ratio_abund if ratio_abund is not None else "-", + ) + ) + ) + cells.append(self._make_item("%.2f ± %.2f" % (100 * enr, 100 * enr_std) if enr is not None and enr_std is not None else ("%.2f" % (100 * enr) if enr is not None else ""))) + + for col_idx, cell in enumerate(cells): + if bg_color is not None: + cell.setBackground(QtGui.QBrush(bg_color)) + self.fileTable.setItem(row_idx, col_idx, cell) + + self.fileTable.setSortingEnabled(True) + self.fileTable.resizeColumnsToContents() + + def _add_summary_row(self, label, value): + row = self.summaryTable.rowCount() + self.summaryTable.insertRow(row) + self.summaryTable.setItem(row, 0, self._make_item(label)) + self.summaryTable.setItem(row, 1, self._make_num_item(value) if isinstance(value, (int, float)) else self._make_item(str(value) if value is not None else "")) + + def _add_summary_section(self, title): + """Add a bold header row.""" + row = self.summaryTable.rowCount() + self.summaryTable.insertRow(row) + header_item = QtWidgets.QTableWidgetItem(title) + header_item.setFlags(header_item.flags() & ~QtCore.Qt.ItemIsEditable) + font = header_item.font() + font.setBold(True) + header_item.setFont(font) + header_item.setBackground(QtGui.QBrush(QtGui.QColor(230, 230, 230))) + self.summaryTable.setItem(row, 0, header_item) + empty = QtWidgets.QTableWidgetItem("") + empty.setBackground(QtGui.QBrush(QtGui.QColor(230, 230, 230))) + empty.setFlags(empty.flags() & ~QtCore.Qt.ItemIsEditable) + self.summaryTable.setItem(row, 1, empty) + + def _populate_summary_table(self): + d = self._summary_data + if not d: + self._add_summary_row("No bracketing results available", "") + return + + self._add_summary_section("Overall") + self._add_summary_row("Feature pairs", d.get("n_features", "")) + self._add_summary_row("Metabolites (OGroups)", d.get("n_metabolites", "")) + if d.get("omitted_features") is not None: + self._add_summary_row("Omitted feature pairs", d.get("omitted_features", "")) + + self._add_summary_section("Metabolites by number of feature pairs") + self._add_summary_row("1 feature pair", d.get("features_1", "")) + self._add_summary_row("2 feature pairs", d.get("features_2", "")) + self._add_summary_row("3 feature pairs", d.get("features_3", "")) + self._add_summary_row("4 feature pairs", d.get("features_4", "")) + self._add_summary_row("5 feature pairs", d.get("features_5", "")) + self._add_summary_row(">5 and ≤10 feature pairs", d.get("features_5_to_10", "")) + self._add_summary_row(">10 and ≤20 feature pairs", d.get("features_10_to_20", "")) + self._add_summary_row(">20 feature pairs", d.get("features_gt20", "")) + + self._add_summary_section("Metabolites by ionization mode") + self._add_summary_row("Positive mode only", d.get("pos_only", "")) + self._add_summary_row("Negative mode only", d.get("neg_only", "")) + self._add_summary_row("Both modes", d.get("both_modes", "")) + + self.summaryTable.resizeColumnsToContents() diff --git a/src/runIdentification.py b/src/runIdentification.py deleted file mode 100644 index f238cbf..0000000 --- a/src/runIdentification.py +++ /dev/null @@ -1,3404 +0,0 @@ -# MetExtract II -# Copyright (C) 2015 -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -from __future__ import absolute_import, division, print_function -import base64 -import logging -import os -import platform -import time -import traceback -from . import HCA_general, Baseline, exportAsFeatureML -from .utils import CallBackMethod, getNormRatio, getDBSuffix -from .Chromatogram import Chromatogram -from .formulaTools import formulaTools, getIsotopeMass -from .mePyGuis.TracerEdit import ConfiguredTracer -from .MZHCA import HierarchicalClustering, cutTreeSized -from .PolarsDB import PolarsDB -from .runIdentification_matchPartners import matchPartners -from .SGR import SGRGenerator -from .chromPeakPicking.peakpickers import filter_peaks -import numpy as np -import polars as pl -import scipy -from pickle import dumps -from copy import copy -from math import floor -from .utils import ( - Bunch, - ChromPeakPair, - corr, - getAtomAdd, - getDBFormat, - getSubGraphs, - mean, - sd, - smoothDataSeries, - weightedMean, - weightedSd, -) - - -# returns the abbreviation for a given element -# e.g. element="Carbon" --> return="C" -def getShortName(element): - fT = formulaTools() - for i in fT.elemDetails: - d = fT.elemDetails[i] - if d[0] == element: - return d[1] - return "" - - -# counts how ofter an entry i is listed in the vector x and returns it as a dictionary -# e.g. x=[1,2,1,3,4,4,5,4] --> return={1:2, 2:1, 3:1, 4:3, 5:1} -def countEntries(x): - ret = {} - for i in x: - if i not in ret: - ret[i] = 0 - ret[i] = ret[i] + 1 - return ret - - -def getCorrelationShifted(xic1, xic2, peakStartMin, peakEndMin, rtShiftsMin=None): - if rtShiftsMin is None: - raise RuntimeError("parameter rtShiftsMin must be an array of retention time shifts in minutes, e.g., [-3.0, -2.6, -2.2, ... 2.2, 2.6, 3.0]") - - if type(rtShiftsMin) != list and type(rtShiftsMin) != np.ndarray: - raise RuntimeError("parameter rtShiftsMin must be an array/numpy ndarray of retention time shifts in minutes, e.g., [-3.0, -2.6, -2.2, ... 2.2, 2.6, 3.0]") - - assert xic1.shape[0] == xic2.shape[0] - - corrs = [] - nonShiftCor = correlationFor(xic1, xic2, peakStartMin, peakEndMin) - - for rtShiftMini, rtShiftMin in enumerate(rtShiftsMin): - ## shift second EIC by current rtShiftMin - xic2_ = np.copy(xic2) - xic2_[:, 0] = xic2_[:, 0] + rtShiftMin - - ## get new rts - newRTs = np.sort(np.concatenate((xic1[:, 0], xic2_[:, 0]))) - - xic1_ = interpolateToNewRTs(np.copy(xic1), np.arange(np.min(newRTs), np.max(newRTs) + 0.0001, 0.01)) - xic2_ = interpolateToNewRTs(xic2_, np.arange(np.min(newRTs), np.max(newRTs) + 0.0001, 0.01)) - ## calculate correlation using peak 1 - c = correlationFor(xic1_, xic2_, peakStartMin, peakEndMin) - corrs.append(c) - - return nonShiftCor, np.max(corrs), rtShiftsMin[np.argmax(corrs)] - - -def interpolateToNewRTs(xic, newRTs): - newRTs = np.unique(newRTs) - - nxic = np.zeros((len(newRTs), 2), dtype=xic.dtype) - for curPosN, rt in enumerate(newRTs): - nxic[curPosN, 0] = rt - - if rt <= xic[0, 0]: - nxic[curPosN, 1] = xic[0, 1] - - elif rt >= xic[xic.shape[0] - 1, 0]: - nxic[curPosN, 1] = xic[xic.shape[0] - 1, 1] - else: - bestNextRtInd = np.argmin(np.abs(rt - xic[:, 0])) - - if xic[bestNextRtInd, 0] == rt: - nxic[curPosN, :] = xic[bestNextRtInd, :] - - else: - leftInd = None - rightInd = None - - if xic[bestNextRtInd, 0] < rt: - leftInd = bestNextRtInd - rightInd = bestNextRtInd + 1 - - elif xic[bestNextRtInd, 0] > rt: - leftInd = bestNextRtInd - 1 - rightInd = bestNextRtInd - - assert leftInd is not None, "Branch should not exists..." - - nxic[curPosN, 1] = xic[leftInd, 1] + (xic[rightInd, 1] - xic[leftInd, 1]) / (xic[rightInd, 0] - xic[leftInd, 0]) * (rt - xic[leftInd, 0]) - - return np.copy(nxic) - - -def smooth(abundances): - algorithm = "rollingaverage" - if algorithm.lower() == "rollingaverage": - kernel = np.ones(kernel_size) / kernel_size - data_convolved = np.convolve(abundances, kernel, mode="same") - return data_convolved - - elif algorithm.lower() == "savgol": - window_length = self.smoothingwindow_length - polynom_degree = self.smoothingpolynom_degree - return scipy.signal.savgol_filter(abundances, window_length, polynom_degree) - - else: - raise RuntimeError("Unknown algorithm '%s' for smoothing" % (algorithm)) - - -def correlationFor(xic1, xic2, peakStartMin, peakEndMin): - algorithm = "pearson" - - leftInd = np.argmin(np.abs(xic1[:, 0] - peakStartMin)) - rightInd = np.argmin(np.abs(xic1[:, 0] - peakEndMin)) - - if algorithm == "pearson": - try: - return corr(xic1[leftInd:rightInd, 1], xic2[leftInd:rightInd, 1]) - except Exception as ex: - print("There was an error calculating the pearson correlation between the two provided XICs. these are") - print("XIC 1") - print(xic1) - print("") - print("XIC 2") - print(xic2) - print("") - print("Peak start and end are", peakStartMin, peakEndMin) - raise RuntimeError("Could not calcualte correlation: '%s'" % (ex)) - - else: - raise RuntimeError("Unknown algorithm '%s' for smoothing" % (algorithm)) - - -peakAbundanceUseSignals = 5 -peakAbundanceUseSignalsSides = int((peakAbundanceUseSignals - 1) / 2) - - -# This class is used as a Command for each LC-HRMS file and is called by the multiprocessing module in MExtract.py -# during the processing of each individual LC-HRMS data files -class FindIsoPairs: - # Constructor of the class, which stores the processing parameters - # writeMZXML: 0001: 12C 0010: 12C-Iso 0100: 13C-Iso 1000: 13C - def __init__( - self, - file, - exOperator="", - exExperimentID="", - exComments="", - exExperimentName="", - writeFeatureML=False, - writeTSV=False, - writeMZXML=9, - metabolisationExperiment=False, - labellingisotopeA="12C", - labellingisotopeB="13C", - useCIsotopePatternValidation=False, - xOffset=1.00335, - minRatio=0, - maxRatio=9999999, - useRatio=False, - configuredTracer=None, - intensityThreshold=0, - intensityCutoff=0, - maxLoading=1, - xCounts="", - ppm=2.0, - isotopicPatternCountLeft=2, - isotopicPatternCountRight=2, - lowAbundanceIsotopeCutoff=True, - intensityThresholdIsotopologs=1000, - intensityErrorN=0.25, - intensityErrorL=0.25, - purityN=0.99, - purityL=0.99, - minSpectraCount=1, - clustPPM=8.0, - chromPeakPPM=5.0, - snrTh=1.0, - scales=[1, 35], - peakCenterError=5, - peakScaleError=3, - minPeakCorr=0.85, - checkPeaksRatio=False, - minPeaksRatio=0, - maxPeaksRatio=99999999, - checkPeakWidthFilter=False, - minPeakWidth=0.0, - maxPeakWidth=9999.0, - minFWHM=0.0, - maxFWHM=9999.0, - calcIsoRatioNative=1, - calcIsoRatioLabelled=-1, - calcIsoRatioMoiety=1, - startTime=2, - stopTime=37, - positiveScanEvent="None", - negativeScanEvent="None", - eicSmoothingWindow="None", - eicSmoothingWindowSize=0, - eicSmoothingPolynom=0, - artificialMPshift_start=0, - artificialMPshift_stop=0, - correctCCount=True, - minCorrelation=0.85, - minCorrelationConnections=0.4, - hAIntensityError=5.0, - hAMinScans=3, - adducts=[], - elements=[], - heteroAtoms=[], - simplifyInSourceFragments=True, - chromPeakFile=None, - lock=None, - queue=None, - pID=-1, - meVersion="NA", - scanIndexOffset=0, - peak_picker=None, - peak_filter_config=None, - ): - # System - self.lock = lock - self.queue = queue - self.pID = pID - self.meVersion = meVersion - - self.peak_picker = peak_picker - self.peak_filter_config = peak_filter_config - # Check if configuredTracer is None when metabolisationExperiment is True - if metabolisationExperiment and configuredTracer is None: - self.printMessage("Metabolisation experiment requires a configured tracer, but none was provided.", type="error") - raise Exception("Metabolisation experiment requires a configured tracer, but none was provided.") - - ma, ea = getIsotopeMass(configuredTracer.isotopeA if metabolisationExperiment else labellingisotopeA) - mb, eb = getIsotopeMass(configuredTracer.isotopeB if metabolisationExperiment else labellingisotopeB) - xOffset = mb - ma if metabolisationExperiment else xOffset - - if (not metabolisationExperiment and len(ea) > 0 and ea == eb) or metabolisationExperiment or (ea == "Hydrogen" and eb == "Deuterium"): - pass - else: - self.printMessage("Labelling specifications are invalid..", type="warning") - raise Exception("Labelling specifications are invalid..") - - if positiveScanEvent == "None" and negativeScanEvent == "None": - self.printMessage("No scan event was specified..", type="warning") - raise Exception("No scan event was specified..") - - self.file = file - self.writeFeatureML = writeFeatureML - self.writeTSV = writeTSV - self.writeMZXML = writeMZXML - - # E. Experiment - self.experimentOperator = exOperator - self.experimentID = exExperimentID - self.experimentComments = exComments - self.experimentName = exExperimentName - - # 0. General - self.startTime = startTime - self.stopTime = stopTime - self.positiveScanEvent = positiveScanEvent - self.negativeScanEvent = negativeScanEvent - - self.metabolisationExperiment = metabolisationExperiment - self.labellingElement = getShortName(ea) - self.isotopeA = ma - self.isotopeB = mb - self.useCIsotopePatternValidation = useCIsotopePatternValidation - self.minRatio = minRatio - self.maxRatio = maxRatio - self.useRatio = useRatio - - self.configuredTracer = configuredTracer - if not self.metabolisationExperiment: - self.configuredTracer = ConfiguredTracer(name="FML", id=0) - - # 1. Mass picking - self.intensityThreshold = intensityThreshold - self.intensityCutoff = intensityCutoff - self.maxLoading = maxLoading - - self.xCounts = [] - self.xCountsString = xCounts - a = xCounts.replace(" ", "").replace(";", ",").split(",") - for j in a: - if "-" in j: - self.xCounts.extend(range(int(j[0 : j.find("-")]), int(j[j.find("-") + 1 : len(j)]) + 1)) - elif ":" in j: - self.xCounts.extend(range(int(j[0 : j.find(":")]), int(j[j.find(":") + 1 : len(j)]) + 1)) - elif j != "": - self.xCounts.append(int(j)) - self.xCounts = sorted(list(set(self.xCounts))) - - self.xOffset = xOffset - self.scanIndexOffset = scanIndexOffset - self.ppm = ppm - self.isotopicPatternCountLeft = isotopicPatternCountLeft - self.isotopicPatternCountRight = isotopicPatternCountRight - self.lowAbundanceIsotopeCutoff = lowAbundanceIsotopeCutoff - self.intensityThresholdIsotopologs = intensityThresholdIsotopologs - self.intensityErrorN = intensityErrorN - self.intensityErrorL = intensityErrorL - self.purityN = purityN - self.purityL = purityL - - # 2. Results clustering - self.minSpectraCount = max(1, minSpectraCount) - self.clustPPM = clustPPM - - # 3. Peak detection - self.chromPeakPPM = chromPeakPPM - - if eicSmoothingWindow is None or eicSmoothingWindow == "" or eicSmoothingWindow == "none": - eicSmoothingWindow = "None" - self.eicSmoothingWindow = eicSmoothingWindow - self.eicSmoothingWindowSize = eicSmoothingWindowSize - self.eicSmoothingPolynom = eicSmoothingPolynom - self.artificialMPshift_start = artificialMPshift_start - self.artificialMPshift_stop = artificialMPshift_stop - self.snrTh = snrTh - self.scales = scales - self.peakCenterError = peakCenterError - self.peakScaleError = peakScaleError - self.minPeakCorr = minPeakCorr - self.checkPeaksRatio = checkPeaksRatio - self.minPeaksRatio = minPeaksRatio - self.maxPeaksRatio = maxPeaksRatio - self.checkPeakWidthFilter = checkPeakWidthFilter - self.minPeakWidth = minPeakWidth - self.maxPeakWidth = maxPeakWidth - self.minFWHM = minFWHM - self.maxFWHM = maxFWHM - - self.calcIsoRatioNative = calcIsoRatioNative - self.calcIsoRatioLabelled = calcIsoRatioLabelled - self.calcIsoRatioMoiety = calcIsoRatioMoiety - - self.chromPeakFile = chromPeakFile # kept for backward compatibility (no longer used) - - self.performCorrectCCount = correctCCount - - self.minCorrelation = minCorrelation - self.minCorrelationConnections = minCorrelationConnections - - self.hAIntensityError = hAIntensityError - self.hAMinScans = hAMinScans - self.adducts = adducts - self.elements = {} - for elem in elements: - self.elements[elem.name] = elem - self.heteroAtoms = {} - for heteroAtom in heteroAtoms: - self.heteroAtoms[heteroAtom.name] = heteroAtom - self.simplifyInSourceFragments = simplifyInSourceFragments - - # Thread safe printing function - def printMessage(self, message, type="info"): - # Always print to stdout for debugging - if self.lock is not None: - self.lock.acquire() - if type.lower() == "info": - # logging.info(" %d: %s" % (self.pID, message)) - self.postMessageToProgressWrapper(mes="log", val=message) - elif type.lower() == "warning": - # logging.warning(" %d: %s" % (self.pID, message)) - self.postMessageToProgressWrapper(mes="log", val=message) - elif type.lower() == "error": - # logging.error(" %d: %s" % (self.pID, message)) - self.postMessageToProgressWrapper(mes="log", val=message) - else: - # logging.debug(" %d: %s" % (self.pID, message)) - self.postMessageToProgressWrapper(mes="log", val=message) - self.lock.release() - - # helper function used to update the status of the current processing in the Process Dialog - def postMessageToProgressWrapper(self, mes, val=""): - # Always print to stdout for debugging - if self.pID != -1 and self.queue is not None: - if mes.lower() == "text": - self.queue.put(Bunch(pid=self.pID, mes="text", val="%d: %s" % (self.pID, val))) - elif mes == "value" or mes == "max": - self.queue.put(Bunch(pid=self.pID, mes=mes, val=val)) - elif mes == "start" or mes == "end" or mes == "failed": - self.queue.put(Bunch(pid=self.pID, mes=mes)) - elif mes == "log": - self.queue.put(Bunch(pid=self.pID, mes=mes, val=mes)) - - def getMostLikelyHeteroIsotope(self, foundIsotopes): - if len(foundIsotopes) == 0: - return - - for iso in foundIsotopes: - isoD = foundIsotopes[iso] - useCn = -1 - useCnRatio = 0.0 - useCnRatioTheo = 0.0 - - for cn in isoD: - cnF = isoD[cn] - - cnRatio = mean([x[1] for x in cnF]) - cnRatioTheo = mean([x[2] for x in cnF]) - len(cnF) - - if useCn == -1 or (abs(cnRatioTheo - cnRatio) < abs(useCnRatio - useCnRatioTheo)): - useCn = cn - useCnRatio = cnRatio - useCnRatioTheo = cnRatioTheo - - foundIsotopes[iso] = {useCn: isoD[useCn]} - - # store configuration used for processing the LC-HRMS file into the database - def writeConfigurationToDB(self, db_con): - # db_con is a PolarsDB object - # Create empty tables with appropriate schemas - db_con.create_table("config", {"key": pl.Utf8, "value": pl.Utf8}) - - db_con.create_table( - "tracerConfiguration", - { - "id": pl.Int64, - "name": pl.Utf8, - "elementCount": pl.Int64, - "natural": pl.Utf8, - "labelling": pl.Utf8, - "deltaMZ": pl.Float64, - "purityN": pl.Float64, - "purityL": pl.Float64, - "amountN": pl.Float64, - "amountL": pl.Float64, - "monoisotopicRatio": pl.Float64, - "checkRatio": pl.Utf8, - "lowerError": pl.Float64, - "higherError": pl.Float64, - "tracertype": pl.Utf8, - }, - ) - - db_con.create_table("MZs", {"id": pl.Int64, "tracer": pl.Int64, "mz": pl.Float64, "lmz": pl.Float64, "tmz": pl.Float64, "xcount": pl.Int64, "scanid": pl.Int64, "scantime": pl.Float64, "loading": pl.Int64, "intensity": pl.Float64, "intensityL": pl.Float64, "ionMode": pl.Utf8}) - - db_con.create_table("MZBins", {"id": pl.Int64, "mz": pl.Float64, "ionMode": pl.Utf8}) - - db_con.create_table("MZBinsKids", {"mzbinID": pl.Int64, "mzID": pl.Int64}) - - db_con.create_table( - "XICs", - { - "id": pl.Int64, - "avgmz": pl.Float64, - "xcount": pl.Int64, - "loading": pl.Int64, - "polarity": pl.Utf8, - "xic": pl.Utf8, - "xic_smoothed": pl.Utf8, - "xic_baseline": pl.Utf8, - "xicL": pl.Utf8, - "xicL_smoothed": pl.Utf8, - "xicL_baseline": pl.Utf8, - "xicfirstiso": pl.Utf8, - "xicLfirstiso": pl.Utf8, - "xicLfirstisoconjugate": pl.Utf8, - "mzs": pl.Utf8, - "mzsL": pl.Utf8, - "mzsfirstiso": pl.Utf8, - "mzsLfirstiso": pl.Utf8, - "mzsLfirstisoconjugate": pl.Utf8, - "times": pl.Utf8, - "scanCount": pl.Int64, - "allPeaks": pl.Utf8, - }, - ) - - db_con.create_table("tics", {"id": pl.Int64, "polarity": pl.Utf8, "scanevent": pl.Utf8, "times": pl.Utf8, "intensities": pl.Utf8}) - - db_con.create_table( - "chromPeaks", - { - "id": pl.Int64, - "tracer": pl.Int64, - "eicID": pl.Int64, - "NPeakCenter": pl.Int64, - "NPeakCenterMin": pl.Float64, - "NPeakScale": pl.Float64, - "NSNR": pl.Float64, - "NPeakArea": pl.Float64, - "NPeakAbundance": pl.Float64, - "mz": pl.Float64, - "lmz": pl.Float64, - "tmz": pl.Float64, - "xcount": pl.Int64, - "xcountId": pl.Int64, - "LPeakCenter": pl.Int64, - "LPeakCenterMin": pl.Float64, - "LPeakScale": pl.Float64, - "LSNR": pl.Float64, - "LPeakArea": pl.Float64, - "LPeakAbundance": pl.Float64, - "Loading": pl.Int64, - "peaksCorr": pl.Float64, - "heteroAtoms": pl.Utf8, - "NBorderLeft": pl.Int64, - "NBorderRight": pl.Int64, - "LBorderLeft": pl.Int64, - "LBorderRight": pl.Int64, - "N_startRT": pl.Float64, - "N_endRT": pl.Float64, - "L_startRT": pl.Float64, - "L_endRT": pl.Float64, - "adducts": pl.Utf8, - "heteroAtomsFeaturePairs": pl.Utf8, - "massSpectrumID": pl.Int64, - "ionMode": pl.Utf8, - "assignedMZs": pl.Utf8, - "fDesc": pl.Utf8, - "peaksRatio": pl.Float64, - "peaksRatioMp1": pl.Float64, - "peaksRatioMPm1": pl.Float64, - "isotopesRatios": pl.Utf8, - "mzDiffErrors": pl.Utf8, - "isotopologRatios": pl.Utf8, - "peakType": pl.Utf8, - "assignedName": pl.Utf8, - "correlationsToOthers": pl.Utf8, - "comments": pl.Utf8, - "artificialEICLShift": pl.Int64, - }, - ) - - db_con.create_table( - "allChromPeaks", - { - "id": pl.Int64, - "tracer": pl.Int64, - "eicID": pl.Int64, - "NPeakCenter": pl.Int64, - "NPeakCenterMin": pl.Float64, - "NPeakScale": pl.Float64, - "NSNR": pl.Float64, - "NPeakArea": pl.Float64, - "NPeakAbundance": pl.Float64, - "mz": pl.Float64, - "lmz": pl.Float64, - "tmz": pl.Float64, - "xcount": pl.Int64, - "xcountId": pl.Int64, - "LPeakCenter": pl.Int64, - "LPeakCenterMin": pl.Float64, - "LPeakScale": pl.Float64, - "LSNR": pl.Float64, - "LPeakArea": pl.Float64, - "LPeakAbundance": pl.Float64, - "Loading": pl.Int64, - "peaksCorr": pl.Float64, - "heteroAtoms": pl.Utf8, - "NBorderLeft": pl.Int64, - "NBorderRight": pl.Int64, - "LBorderLeft": pl.Int64, - "LBorderRight": pl.Int64, - "N_startRT": pl.Float64, - "N_endRT": pl.Float64, - "L_startRT": pl.Float64, - "L_endRT": pl.Float64, - "adducts": pl.Utf8, - "heteroAtomsFeaturePairs": pl.Utf8, - "ionMode": pl.Utf8, - "assignedMZs": pl.Int64, - "fDesc": pl.Utf8, - "peaksRatio": pl.Float64, - "peaksRatioMp1": pl.Float64, - "peaksRatioMPm1": pl.Float64, - "isotopesRatios": pl.Utf8, - "mzDiffErrors": pl.Utf8, - "isotopologRatios": pl.Utf8, - "peakType": pl.Utf8, - "assignedName": pl.Utf8, - "comment": pl.Utf8, - "comments": pl.Utf8, - "artificialEICLShift": pl.Int64, - }, - ) - - db_con.create_table("featureGroups", {"id": pl.Int64, "featureName": pl.Utf8, "tracer": pl.Int64}) - - db_con.create_table("featureGroupFeatures", {"id": pl.Int64, "fID": pl.Int64, "fDesc": pl.Utf8, "fGroupID": pl.Int64}) - - db_con.create_table("featurefeatures", {"fID1": pl.Int64, "fID2": pl.Int64, "corr": pl.Float64, "silRatioValue": pl.Float64, "desc1": pl.Utf8, "desc2": pl.Utf8, "add1": pl.Utf8, "add2": pl.Utf8}) - - db_con.create_table("massspectrum", {"mID": pl.Int64, "fgID": pl.Int64, "time": pl.Float64, "mzs": pl.Utf8, "intensities": pl.Utf8, "ionMode": pl.Utf8}) - - db_con.create_table("stats", {"key": pl.Utf8, "value": pl.Utf8}) - - db_con.insert_row("config", {"key": "MetExtractVersion", "value": self.meVersion}) - - db_con.insert_row("config", {"key": "ExperimentName", "value": self.experimentName}) - db_con.insert_row("config", {"key": "ExperimentOperator", "value": self.experimentOperator}) - db_con.insert_row("config", {"key": "ExperimentID", "value": self.experimentID}) - db_con.insert_row("config", {"key": "ExperimentComments", "value": self.experimentComments}) - - db_con.insert_row("config", {"key": "labellingElement", "value": self.labellingElement}) - db_con.insert_row("config", {"key": "isotopeA", "value": self.isotopeA}) - db_con.insert_row("config", {"key": "isotopeB", "value": self.isotopeB}) - db_con.insert_row("config", {"key": "useCValidation", "value": self.useCIsotopePatternValidation}) - db_con.insert_row("config", {"key": "minRatio", "value": self.minRatio}) - db_con.insert_row("config", {"key": "maxRatio", "value": self.maxRatio}) - db_con.insert_row("config", {"key": "useRatio", "value": str(self.useRatio)}) - db_con.insert_row("config", {"key": "metabolisationExperiment", "value": str(self.metabolisationExperiment)}) - db_con.insert_row("config", {"key": "configuredTracer", "value": base64.b64encode(dumps(self.configuredTracer)).decode("utf-8")}) - db_con.insert_row("config", {"key": "startTime", "value": self.startTime}) - db_con.insert_row("config", {"key": "stopTime", "value": self.stopTime}) - db_con.insert_row("config", {"key": "positiveScanEvent", "value": self.positiveScanEvent}) - db_con.insert_row("config", {"key": "negativeScanEvent", "value": self.negativeScanEvent}) - db_con.insert_row("config", {"key": "intensityThreshold", "value": self.intensityThreshold}) - db_con.insert_row("config", {"key": "intensityCutoff", "value": self.intensityCutoff}) - db_con.insert_row("config", {"key": "maxLoading", "value": self.maxLoading}) - db_con.insert_row("config", {"key": "xCounts", "value": self.xCountsString}) - db_con.insert_row("config", {"key": "xOffset", "value": self.xOffset}) - db_con.insert_row("config", {"key": "scanIndexOffset", "value": self.scanIndexOffset}) - db_con.insert_row("config", {"key": "ppm", "value": self.ppm}) - db_con.insert_row("config", {"key": "isotopicPatternCountLeft", "value": self.isotopicPatternCountLeft}) - db_con.insert_row("config", {"key": "isotopicPatternCountRight", "value": self.isotopicPatternCountRight}) - db_con.insert_row("config", {"key": "lowAbundanceIsotopeCutoff", "value": str(self.lowAbundanceIsotopeCutoff)}) - db_con.insert_row("config", {"key": "intensityThresholdIsotopologs", "value": str(self.intensityThresholdIsotopologs)}) - db_con.insert_row("config", {"key": "intensityErrorN", "value": self.intensityErrorN}) - db_con.insert_row("config", {"key": "intensityErrorL", "value": self.intensityErrorL}) - db_con.insert_row("config", {"key": "purityN", "value": self.purityN}) - db_con.insert_row("config", {"key": "purityL", "value": self.purityL}) - db_con.insert_row("config", {"key": "clustPPM", "value": self.clustPPM}) - db_con.insert_row("config", {"key": "chromPeakPPM", "value": self.chromPeakPPM}) - db_con.insert_row("config", {"key": "eicSmoothing", "value": self.eicSmoothingWindow}) - db_con.insert_row("config", {"key": "eicSmoothingWindowSize", "value": self.eicSmoothingWindowSize}) - db_con.insert_row("config", {"key": "eicSmoothingPolynom", "value": self.eicSmoothingPolynom}) - db_con.insert_row("config", {"key": "artificialMPshift_start", "value": self.artificialMPshift_start}) - db_con.insert_row("config", {"key": "artificialMPshift_stop", "value": self.artificialMPshift_stop}) - db_con.insert_row("config", {"key": "peakAbundanceCriteria", "value": "Center +- %d signals (%d total)" % (peakAbundanceUseSignalsSides, peakAbundanceUseSignals)}) - db_con.insert_row("config", {"key": "minPeakCorr", "value": self.minPeakCorr}) - db_con.insert_row("config", {"key": "checkPeaksRatio", "value": str(self.checkPeaksRatio)}) - db_con.insert_row("config", {"key": "minPeaksRatio", "value": self.minPeaksRatio}) - db_con.insert_row("config", {"key": "maxPeaksRatio", "value": self.maxPeaksRatio}) - db_con.insert_row("config", {"key": "calcIsoRatioNative", "value": self.calcIsoRatioNative}) - db_con.insert_row("config", {"key": "calcIsoRatioLabelled", "value": self.calcIsoRatioLabelled}) - db_con.insert_row("config", {"key": "calcIsoRatioMoiety", "value": self.calcIsoRatioMoiety}) - db_con.insert_row("config", {"key": "minSpectraCount", "value": self.minSpectraCount}) - db_con.insert_row("config", {"key": "configuredHeteroAtoms", "value": base64.b64encode(dumps(self.heteroAtoms)).decode("utf-8")}) - db_con.insert_row("config", {"key": "haIntensityError", "value": self.hAIntensityError}) - db_con.insert_row("config", {"key": "haMinScans", "value": self.hAMinScans}) - db_con.insert_row("config", {"key": "minCorrelation", "value": self.minCorrelation}) - db_con.insert_row("config", {"key": "minCorrelationConnections", "value": self.minCorrelationConnections}) - db_con.insert_row("config", {"key": "adducts", "value": base64.b64encode(dumps(self.adducts)).decode("utf-8")}) - db_con.insert_row("config", {"key": "elements", "value": base64.b64encode(dumps(self.elements)).decode("utf-8")}) - db_con.insert_row("config", {"key": "simplifyInSourceFragments", "value": str(self.simplifyInSourceFragments)}) - - import datetime - import uuid - - self.processingUUID = "%s_%s_%s" % ( - str(uuid.uuid1()), - str(platform.node()), - str(datetime.datetime.now()), - ) - db_con.insert_row("config", {"key": "processingUUID_ext", "value": base64.b64encode(str(self.processingUUID).encode("utf-8"))}) - - i = 1 - if self.metabolisationExperiment: - tracer = self.configuredTracer - tracer.id = i - db_con.insert_row( - "tracerConfiguration", - { - "id": tracer.id, - "name": tracer.name, - "elementCount": tracer.elementCount, - "natural": tracer.isotopeA, - "labelling": tracer.isotopeB, - "deltaMZ": getIsotopeMass(tracer.isotopeB)[0] - getIsotopeMass(tracer.isotopeA)[0], - "purityN": tracer.enrichmentA, - "purityL": tracer.enrichmentB, - "amountN": tracer.amountA, - "amountL": tracer.amountB, - "monoisotopicRatio": tracer.monoisotopicRatio, - "checkRatio": str(tracer.checkRatio), - "lowerError": tracer.maxRelNegBias, - "higherError": tracer.maxRelPosBias, - "tracertype": tracer.tracerType, - }, - ) - - else: - # ConfiguredTracer(name="Full metabolome labeling experiment", id=0) - db_con.insert_row("tracerConfiguration", {"id": 0, "name": "FLE"}) - db_con.commit() - - def parseMzXMLFile(self): - mzxml = Chromatogram() - mzxml.parse_file(self.file, intensityCutoff=self.intensityCutoff) - return mzxml - - # data processing step 1: searches each mass spectrum for isotope patterns of native and highly isotope enriched - # metabolite ions. The actual calculation and processing of the data is performed in the file runIdentification_matchPartners.py. - # The positive and negative ionisation modes are processed separately. - def findSignalPairs(self, curProgress, mzxml, tracer, reportFunction=None): - mzs = [] - posFound = 0 - negFound = 0 - - checkRatio = False - minRatio = 0.0 - maxRatio = 0.0 - - if self.metabolisationExperiment: - checkRatio = tracer.checkRatio - minRatio = tracer.monoisotopicRatio * tracer.maxRelNegBias - maxRatio = tracer.monoisotopicRatio * tracer.maxRelPosBias - else: - checkRatio = self.useRatio - minRatio = self.minRatio - maxRatio = self.maxRatio - - def reportFunctionHelper(curVal, text): - reportFunction(curVal, text) - - if self.positiveScanEvent != "None": - if self.negativeScanEvent != "None": - - def reportFunctionHelper(curVal, text): - reportFunction(curVal / 2, text) - - p = matchPartners( - mzXMLData=mzxml, - forFile=self.file, - labellingElement=self.labellingElement, - useCIsotopePatternValidation=self.useCIsotopePatternValidation, - intensityThres=self.intensityThreshold, - isotopologIntensityThres=self.intensityThresholdIsotopologs, - maxLoading=self.maxLoading, - xCounts=self.xCounts, - xOffset=self.xOffset, - ppm=self.ppm, - intensityErrorN=self.intensityErrorN, - intensityErrorL=self.intensityErrorL, - purityN=tracer.enrichmentA if self.metabolisationExperiment else self.purityN, - purityL=tracer.enrichmentB if self.metabolisationExperiment else self.purityL, - startTime=self.startTime, - stopTime=self.stopTime, - filterLine=self.positiveScanEvent, - ionMode="+", - peakCountLeft=self.isotopicPatternCountLeft, - peakCountRight=self.isotopicPatternCountRight, - lowAbundanceIsotopeCutoff=self.lowAbundanceIsotopeCutoff, - metabolisationExperiment=self.metabolisationExperiment, - checkRatio=checkRatio, - minRatio=minRatio, - maxRatio=maxRatio, - scanIndexOffset=self.scanIndexOffset, - reportFunction=reportFunctionHelper, - ) - posFound = len(p) - mzs.extend(p) - - def reportFunctionHelper(curVal, text): - reportFunction(curVal, text) - - if self.negativeScanEvent != "None": - if self.positiveScanEvent != "None": - - def reportFunctionHelper(curVal, text): - reportFunction(0.5 + curVal / 2, text) - - n = matchPartners( - mzXMLData=mzxml, - forFile=self.file, - labellingElement=self.labellingElement, - useCIsotopePatternValidation=self.useCIsotopePatternValidation, - intensityThres=self.intensityThreshold, - isotopologIntensityThres=self.intensityThresholdIsotopologs, - maxLoading=self.maxLoading, - xCounts=self.xCounts, - xOffset=self.xOffset, - ppm=self.ppm, - intensityErrorN=self.intensityErrorN, - intensityErrorL=self.intensityErrorL, - purityN=tracer.enrichmentA if self.metabolisationExperiment > 1 else self.purityN, - purityL=tracer.enrichmentB if self.metabolisationExperiment > 1 else self.purityL, - startTime=self.startTime, - stopTime=self.stopTime, - filterLine=self.negativeScanEvent, - ionMode="-", - peakCountLeft=self.isotopicPatternCountLeft, - peakCountRight=self.isotopicPatternCountRight, - lowAbundanceIsotopeCutoff=self.lowAbundanceIsotopeCutoff, - metabolisationExperiment=self.metabolisationExperiment, - checkRatio=checkRatio, - minRatio=minRatio, - maxRatio=maxRatio, - scanIndexOffset=self.scanIndexOffset, - reportFunction=reportFunctionHelper, - ) - negFound = len(n) - mzs.extend(n) - - return mzs, negFound, posFound - - # store detected signal pairs (1st data processing step) in the database - def writeSignalPairsToDB(self, mzs, mzxml, tracerID): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - for mz in mzs: - mz.id = self.curMZId - mz.tid = tracerID - - scanEvent = "" - if mz.ionMode == "+": - scanEvent = self.positiveScanEvent - elif mz.ionMode == "-": - scanEvent = self.negativeScanEvent - - db_con.insert_row( - "MZs", - { - "id": mz.id, - "tracer": mz.tid, - "mz": mz.mz, - "lmz": mz.lmz, - "tmz": mz.tmz, - "xcount": mz.xCount, - "scanid": mzxml.getIthMS1Scan(mz.scanIndex, scanEvent).id, - "scanTime": mzxml.getIthMS1Scan(mz.scanIndex, scanEvent).retention_time, - "loading": mz.loading, - "intensity": mz.nIntensity, - "intensityL": mz.lIntensity, - "ionMode": mz.ionMode, - }, - ) - - self.curMZId = self.curMZId + 1 - - db_con.commit() - db_con.close() - - # data processing step 2: cluster detected signal pairs with H - def clusterFeaturePairs(self, mzs, reportFunction=None): - mzbins = {} - mzbins["+"] = [] - mzbins["-"] = [] - - uniquexCounts = list(set([mz.xCount for mz in mzs])) - - # cluster each detected number of carbon atoms separately - donei = 0 - for xCount in uniquexCounts: - if reportFunction is not None: - reportFunction(donei / len(uniquexCounts), "Current Xn: %s" % xCount) - # cluster each detected number of loadings separately - for loading in range(self.maxLoading, 0, -1): - for ionMode in ["+", "-"]: - xAct = sorted( - [mz for mz in mzs if mz.ionMode == ionMode and mz.xCount == xCount and mz.loading == loading], - key=lambda x: x.mz, - ) - - doClusterings = [] - - if len(xAct) > 0: - lastUnused = 0 - usedCount = 0 - - # non-consecutive mz regions are prematurely separated without HCA to reduce the number of - # signal pairs for HCA (faster processing) - for i in range(1, len(xAct)): - ppmDiff = (xAct[i].mz - xAct[i - 1].mz) * 1000000 / xAct[i - 1].mz - if ppmDiff > self.clustPPM: - doClusterings.append(xAct[lastUnused:i]) - - usedCount = usedCount + i - lastUnused - lastUnused = i - - # cluster remainign signal pairs - if lastUnused < len(xAct): - doClusterings.append(xAct[lastUnused : len(xAct)]) - usedCount = usedCount + len(xAct) - lastUnused - - assert usedCount == len(xAct) - - temp = doClusterings - doClusterings = [] - for t in temp: - xAct = sorted([mz for mz in t], key=lambda x: x.scanIndex) - - lastUnused = 0 - usedCount = 0 - - for i in range(1, len(xAct)): - scanDiff = xAct[i].scanIndex - xAct[i - 1].scanIndex - if scanDiff > 20: - doClusterings.append(xAct[lastUnused:i]) - - usedCount = usedCount + i - lastUnused - lastUnused = i - - # cluster remainign signal pairs - if lastUnused < len(xAct): - doClusterings.append(xAct[lastUnused : len(xAct)]) - usedCount = usedCount + len(xAct) - lastUnused - - assert usedCount == len(xAct) - - for doClustering in doClusterings: - hc = HierarchicalClustering( - doClustering, - dist=lambda x, y: x.getValue() - y.getValue(), - val=lambda x: x.mz, - mean=lambda x, y: x / y, - add=lambda x, y: x + y, - ) - - for n in cutTreeSized(hc.getTree(), self.clustPPM): - mzbins[ionMode].append(n) - - return mzbins - - # store signal pair clusters (2nd data processing step) in the database - def writeFeaturePairClustersToDB(self, mzbins): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - for ionMode in ["+", "-"]: - for mzbin in mzbins[ionMode]: - db_con.insert_row("MZBins", {"id": self.curMZBinId, "mz": mzbin.getValue(), "ionMode": ionMode}) - for kid in mzbin.getKids(): - db_con.insert_row("MZBinsKids", {"mzbinID": self.curMZBinId, "mzID": kid.getObject().id}) - self.curMZBinId = self.curMZBinId + 1 - - db_con.commit() - db_con.close() - - def removeImpossibleFeaturePairClusters(self, mzbins): - mzBinsNew = {} - for ionMode in mzbins.keys(): - mzBinsNew[ionMode] = [mzbin for mzbin in mzbins[ionMode] if len(mzbin.getKids()) >= self.minSpectraCount] - return mzBinsNew - - # EXPERIMENTAL: calulcate the mean intensity ratio of two chromatographic peaks at the same retention time - # in two different EICs. Thus, the internal standardisation is not calculated using the peak area - # but rather the ratio of each MS peak that contributes to the chromatographic peak. - def getMeanRatioOfScans(self, eicA, eicB, lib, rib, perfWeighted=True, minInt=1000, minRatiosNecessary=3): - try: - if perfWeighted: - sumEIC = sum(eicA[lib:rib]) - sumEICL = sum(eicB[lib:rib]) - - normEIC = eicA - if sumEICL > sumEIC: - normEIC = eicB - - os = [o for o in range(lib, rib) if eicA[o] >= minInt and eicB[o] >= minInt and eicB[o] > 0] - normSum = sum([normEIC[o] for o in os]) - weights = [1.0 * normEIC[o] / normSum for o in os] - ratios = [1.0 * eicA[o] / eicB[o] for o in os if eicB[o] > minInt and eicB[o] > 0] - - if len(ratios) >= minRatiosNecessary: - assert len(ratios) == len(weights) - ratio = sum([ratios[o] * weights[o] for o in range(len(ratios))]) - sum([ratios[o] * weights[o] for o in range(len(ratios))]) - return ratio - else: - return -1 - else: - ratios = [(eicA[o] / eicB[o]) for o in range(lib, rib) if eicA[o] >= minInt and eicB[o] >= minInt] - if len(ratios) >= minRatiosNecessary: - return mean(ratios) - else: - return -1 - - except IndexError: - return -1 - - # data processing step 3: for each signal pair cluster extract the EICs, detect chromatographic peaks - # present in both EICs at approximately the same retention time and very their chromatographic peak shapes. - # if all criteria are passed, write this detected feature pair to the database - def findChromatographicPeaksAndWriteToDB(self, mzbins, mzxml, tracerID, reportFunction=None): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - chromPeaks = [] - - totalBins = len(mzbins["+"]) + len(mzbins["-"]) - - # process each signal pair cluster - for ionMode in ["+", "-"]: - if ionMode == "+": - scanEvent = self.positiveScanEvent - elif ionMode == "-": - scanEvent = self.negativeScanEvent - else: - raise Exception("Unknown Ion Mode") - - if scanEvent != "None": - for mzbin, i in zip(mzbins[ionMode], range(0, len(mzbins[ionMode]))): - try: - if reportFunction is not None: - doneBins = i - if ionMode == "-": - doneBins += len(mzbins["+"]) - - reportFunction( - 1.0 * doneBins / totalBins, - "%d mzbins remaining" % (totalBins - doneBins), - ) - - kids = mzbin.getKids() - if len(kids) < self.minSpectraCount: - continue - - # calulcate mean mz value for this signal pair cluster - meanmz = weightedMean( - [kid.getObject().mz for kid in kids], - [kid.getObject().nIntensity for kid in kids], - ) - meanmzLabelled = weightedMean( - [kid.getObject().lmz for kid in kids], - [kid.getObject().lIntensity for kid in kids], - ) - meantmz = weightedMean( - [kid.getObject().tmz for kid in kids], - [kid.getObject().lIntensity for kid in kids], - ) - - xcount = kids[0].getObject().xCount - assert all([kid.getObject().xCount == xcount for kid in kids]) - - loading = kids[0].getObject().loading - assert all([kid.getObject().loading == loading for kid in kids]) - - # extract the EIC of the native ion and detect its chromatographic peaks - # optionally: smoothing - eic, times, scanIds, mzs = mzxml.getEIC(meanmz, self.chromPeakPPM, filterLine=scanEvent) - eicBaseline = self.BL.getBaseline(copy(eic), times) - eicSmoothed = smoothDataSeries( - times, - copy(eic), - windowLen=self.eicSmoothingWindowSize, - window=self.eicSmoothingWindow, - polynom=self.eicSmoothingPolynom, - ) - # extract the EIC of the labelled ion and detect its chromatographic peaks - # optionally: smoothing - eicL, times, scanIds, mzsL = mzxml.getEIC(meanmzLabelled, self.chromPeakPPM, filterLine=scanEvent) - eicLBaseline = self.BL.getBaseline(copy(eicL), times) - eicLSmoothed = smoothDataSeries( - times, - copy(eicL), - windowLen=self.eicSmoothingWindowSize, - window=self.eicSmoothingWindow, - polynom=self.eicSmoothingPolynom, - ) - - ## TODO needs to be optimized - # startIndex=max(0, minInd-int(ceil(self.scales[1]*10))) - # endIndex=min(len(eic)-1, int(ceil(maxInd+self.scales[1]*10))) - startIndex = 0 - endIndex = len(eic) - 1 - - # Use unified peak picker and filter config - peaksN = self.peak_picker.getPeaksFor(times, eicSmoothed, startIndex=startIndex, endIndex=endIndex) - peaksL = self.peak_picker.getPeaksFor(times, eicLSmoothed, startIndex=startIndex, endIndex=endIndex) - - if self.peak_filter_config is not None: - peaksN = filter_peaks(peaksN, config=self.peak_filter_config) - peaksL = filter_peaks(peaksL, config=self.peak_filter_config) - - # get EICs of M+1, M'-1 and M'+1 for the database - eicfirstiso, timesL, scanIdsL, mzsfirstiso = mzxml.getEIC( - meanmz + 1.00335484 / loading, - self.chromPeakPPM, - filterLine=scanEvent, - ) - # eicfirstisoSmoothed = smoothDataSeries(times, eicfirstiso, windowLen=self.eicSmoothingWindowSize, window=self.eicSmoothingWindow, polynom=self.eicSmoothingPolynom) - - eicLfirstiso, timesL, scanIdsL, mzsLfirstiso = mzxml.getEIC( - meanmzLabelled - 1.00335484 / loading, - self.chromPeakPPM, - filterLine=scanEvent, - ) - # eicLfirstisoSmoothed = smoothDataSeries(times, eicLfirstiso, windowLen=self.eicSmoothingWindowSize, window=self.eicSmoothingWindow, polynom=self.eicSmoothingPolynom) - - eicLfirstisoconjugate, timesL, scanIdsL, mzsLfirstisoconjugate = mzxml.getEIC( - meanmzLabelled + 1.00335484 / loading, - self.chromPeakPPM, - filterLine=scanEvent, - ) - # eicLfirstisoconjugateSmoothed = smoothDataSeries(times, eicLfirstisoconjugate, windowLen=self.eicSmoothingWindowSize, window=self.eicSmoothingWindow, polynom=self.eicSmoothingPolynom) - - peaksBoth = [] - - # match detected chromatographic peaks from both EICs - for peakN in peaksN: - closestMatch = -1 - closestOffset = 10000000000 - - # search closest peak pair - for li, peakL in enumerate(peaksL): - if abs(peakN.apex_index - peakL.apex_index) <= self.peakCenterError: - if closestMatch == -1 or closestOffset > abs(peakN.apex_index - peakL.apex_index): - closestMatch = li - closestOffset = abs(peakN.apex_index - peakL.apex_index) - - if closestMatch != -1: - peakL = peaksL[closestMatch] - - # print("M: ", int(peakN.peakIndex - peakN.peakLeftFlank), peakN.peakIndex, int(peakN.peakIndex + peakN.peakRightFlank)) - # print(f" FWHM: {fwhm_M}, area: {area_M}, SNR: {snr_M}") - # print("Mp: ", int(peakL.peakIndex - peakL.peakLeftFlank), peakL.peakIndex, int(peakL.peakIndex + peakL.peakRightFlank)) - # print(f" FWHM: {fwhm_Mp}, area: {area_Mp}, SNR: {snr_Mp}") - - peak = ChromPeakPair( - mz=meanmz, - lmz=meanmzLabelled, - tmz=meantmz, - xCount=xcount, - loading=loading, - ionMode=ionMode, - NPeakCenter=peakN.apex_index, - NPeakCenterMin=peakN.apex_rt, - NPeakScale=(peakN.apex_index - peakN.start_index + peakN.end_index - peakN.apex_index) / 2.0, - NSNR=peakN.snr, - NPeakArea=peakN.area, - LPeakCenter=peakL.apex_index, - LPeakCenterMin=peakL.apex_rt, - LPeakScale=(peakL.apex_index - peakL.start_index + peakL.end_index - peakL.apex_index) / 2.0, - LSNR=peakL.snr, - LPeakArea=peakL.area, - NXIC=eic, - LXIC=eicL, - times=times, - fDesc=[], - adducts=[], - heteroAtomsFeaturePairs=[], - NXICSmoothed=eicSmoothed, - LXICSmoothed=eicLSmoothed, - NBorderLeft=peakN.apex_index - peakN.start_index, - NBorderRight=peakN.end_index - peakN.apex_index, - LBorderLeft=peakL.apex_index - peakL.start_index, - LBorderRight=peakL.end_index - peakL.apex_index, - N_startRT=peakN.start_rt, - N_endRT=peakN.end_rt, - L_startRT=peakL.start_rt, - L_endRT=peakL.end_rt, - isotopeRatios=[], - mzDiffErrors=Bunch(), - comments=[], - artificialEICLShift=0, - ) - - lb = int( - max( - 0, - min( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ), - ) - ) - rb = int( - min( - max( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - + 1, - len(eic) - 1, - ) - ) - peakN = eicSmoothed[lb:rb] - peakL = eicLSmoothed[lb:rb] - - def findBestArtificialShift(eicN, eicL, lb, rb, shiftFrom=0, shiftTo=0): - correlations = [] - - for artShift in range(shiftFrom, shiftTo + 1): - peakN = eicN[lb:rb] - peakL = eicL[(lb + artShift) : (rb + artShift)] - silRatios = [ - peakN[i] / peakL[i] - for i in range( - int(len(peakN) * 0.25), - int(len(peakN) * 0.75) + 1, - ) - if peakL[i] > 0 and peakN[i] > 0 - ] - correlations.append( - Bunch( - correlation=corr(peakN, peakL), - artificialShift=artShift, - silRatios=silRatios, - peakNInts=[ - peakN[i] - for i in range( - int(len(peakN) * 0.25), - int(len(peakN) * 0.75) + 1, - ) - if peakL[i] > 0 and peakN[i] > 0 - ], - peakLInts=[ - peakL[i] - for i in range( - int(len(peakN) * 0.25), - int(len(peakN) * 0.75) + 1, - ) - if peakL[i] > 0 and peakN[i] > 0 - ], - ) - ) - if not correlations: - return None - bestFit = max(correlations, key=lambda x: x.correlation) - - return bestFit - - co = findBestArtificialShift( - eicSmoothed, - eicLSmoothed, - lb, - rb, - self.artificialMPshift_start, - self.artificialMPshift_stop, - ) - - # check peak shape (Pearson correlation) - - if co is not None and co.correlation >= self.minPeakCorr and ((not self.checkPeaksRatio) or self.minPeaksRatio <= (peak.NPeakArea / peak.LPeakArea) <= self.maxPeaksRatio): - peak.peaksCorr = co.correlation - peak.silRatios = Bunch( - silRatios=co.silRatios, - peakNInts=co.peakNInts, - peakLInts=co.peakLInts, - ) - if co.artificialShift != 0: - peak.artificialEICLShift = co.artificialShift - - peaksBoth.append(peak) - - libs = int( - max( - peak.NPeakCenter - floor(peak.NBorderLeft * 0.33), - peak.LPeakCenter - floor(peak.LBorderLeft * 0.33), - ) - ) - ribs = ( - int( - min( - peak.NPeakCenter + floor(peak.NBorderRight * 0.33), - peak.LPeakCenter + floor(peak.LBorderRight * 0.33), - ) - ) - + 1 - ) - - peak.peaksRatio = self.getMeanRatioOfScans( - eic, - eicL, - libs, - ribs, - minInt=self.intensityThreshold, - ) - peak.peaksRatioMp1 = self.getMeanRatioOfScans( - eicfirstiso, - eic, - libs, - ribs, - minInt=self.intensityThreshold, - ) - peak.peaksRatioMPm1 = self.getMeanRatioOfScans( - eicLfirstiso, - eicL, - libs, - ribs, - minInt=self.intensityThreshold, - ) - - # check, if enough MS scans fulfill the requirements (isotope patterns) - for kid in kids: - kido = kid.getObject() - - closestPeak = -1 - distance = len(eic) - i = 0 - for peak in peaksBoth: - if abs(kido.scanIndex - peak.NPeakCenter) < distance: - closestPeak = i - distance = abs(kido.scanIndex - peak.NPeakCenter) - i = i + 1 - if closestPeak != -1 and distance < ( - min( - peaksBoth[closestPeak].NBorderLeft, - peaksBoth[closestPeak].NBorderRight, - ) - * 2 - ): - peaksBoth[closestPeak].assignedMZs.append(kido) - - curChromPeaks = [] - for peak in peaksBoth: - if len(peak.assignedMZs) >= self.minSpectraCount: - curChromPeaks.append(peak) - - for peak in curChromPeaks: - lb = int( - max( - 0, - min( - peak.NPeakCenter - peakAbundanceUseSignalsSides, - peak.LPeakCenter - peakAbundanceUseSignalsSides, - ), - ) - ) - rb = int( - min( - max( - peak.NPeakCenter + peakAbundanceUseSignalsSides, - peak.LPeakCenter + peakAbundanceUseSignalsSides, - ) - + 1, - len(eic) - 1, - ) - ) - peakN = eic[lb:rb] - peakL = eicL[lb:rb] - - peak.NPeakAbundance = max(peakN) # mean(peakN) - peak.LPeakAbundance = max(peakL) # mean(peakL) - - # write detected feature pair to the database - if len(curChromPeaks) > 0: - assert len(eic) == len(eicL) == len(eicfirstiso) == len(eicLfirstiso) == len(eicLfirstisoconjugate) == len(times) - - keepScans = set() - for cs in range(len(eic)): - if eic[cs] > 0 or eicL[cs] > 0 or eicfirstiso[cs] > 0 or eicLfirstiso[cs] > 0 or eicLfirstisoconjugate[cs] > 0: - keepScans.add(cs) - keepScans.add(0) - keepScans.add(len(eic) - 1) - - # save the native and labelled EICs to the database - db_con.insert_row( - "XICs", - { - "id": self.curEICId, - "avgmz": meanmz, - "xcount": xcount, - "loading": kids[0].getObject().loading, - "polarity": ionMode, - "xic": ";".join(["%f" % i for i in eic]), - "xicL": ";".join(["%f" % i for i in eicL]), - "xicfirstiso": ";".join(["%f" % i for i in eicfirstiso]), - "xicLfirstiso": ";".join(["%f" % i for i in eicLfirstiso]), - "xicLfirstisoconjugate": ";".join(["%f" % i for i in eicLfirstisoconjugate]), - "xic_smoothed": ";".join(["%f" % i for i in eicSmoothed]), - "xicL_smoothed": ";".join(["%f" % i for i in eicLSmoothed]), - "xic_baseline": ";".join(["%f" % i for i in eicBaseline]), - "xicL_baseline": ";".join(["%f" % i for i in eicLBaseline]), - "mzs": ";".join(["%f" % (mzs[i]) for i in range(0, len(eic))]), - "mzsL": ";".join(["%f" % (mzsL[i]) for i in range(0, len(eic))]), - "mzsfirstiso": ";".join(["%f" % (mzsfirstiso[i]) for i in range(0, len(eic))]), - "mzsLfirstiso": ";".join(["%f" % (mzsLfirstiso[i]) for i in range(0, len(eic))]), - "mzsLfirstisoconjugate": ";".join(["%f" % (mzsLfirstisoconjugate[i]) for i in range(0, len(eic))]), - "times": ";".join(["%f" % (times[i]) for i in range(0, len(times))]), - "scanCount": len(eic), - "allPeaks": base64.b64encode(dumps({"peaksN": peaksN, "peaksL": peaksL})).decode("utf-8"), - }, - ) - - # save the detected feature pairs in these EICs to the database - for peak in curChromPeaks: - peak.id = self.curPeakId - peak.eicID = self.curEICId - adjcCount = peak.xCount - peak.correctedXCount = peak.xCount - - if self.performCorrectCCount and False: - adjcCount = adjcCount + getAtomAdd(self.purityL, peak.xCount) + getAtomAdd(self.purityN, peak.xCount) - peak.correctedXCount = adjcCount - - db_con.insert_row( - "chromPeaks", - { - "id": peak.id, - "tracer": tracerID, - "eicID": peak.eicID, - "mz": peak.mz, - "lmz": peak.lmz, - "tmz": peak.tmz, - "xcount": peak.correctedXCount, - "xcountId": peak.xCount, - "Loading": peak.loading, - "ionMode": ionMode, - "NPeakCenter": peak.NPeakCenter, - "NPeakCenterMin": peak.NPeakCenterMin, - "NPeakScale": peak.NPeakScale, - "NSNR": peak.NSNR, - "NPeakArea": peak.NPeakArea, - "NPeakAbundance": peak.NPeakAbundance, - "NBorderLeft": peak.NBorderLeft, - "NBorderRight": peak.NBorderRight, - "LPeakCenter": peak.LPeakCenter, - "LPeakCenterMin": peak.LPeakCenterMin, - "LPeakScale": peak.LPeakScale, - "LSNR": peak.LSNR, - "LPeakArea": peak.LPeakArea, - "LPeakAbundance": peak.LPeakAbundance, - "LBorderLeft": peak.LBorderLeft, - "LBorderRight": peak.LBorderRight, - "N_startRT": peak.N_startRT, - "N_endRT": peak.N_endRT, - "L_startRT": peak.L_startRT, - "L_endRT": peak.L_endRT, - "peaksCorr": peak.peaksCorr, - "heteroAtoms": "", - "adducts": "", - "heteroAtomsFeaturePairs": "", - "massSpectrumID": 0, - "assignedMZs": base64.b64encode(dumps(peak.assignedMZs)).decode("utf-8"), - "fDesc": base64.b64encode(dumps([])).decode("utf-8"), - "peaksRatio": peak.peaksRatio, - "peaksRatioMp1": peak.peaksRatioMp1, - "peaksRatioMPm1": peak.peaksRatioMPm1, - "peakType": "patternFound", - "comments": base64.b64encode(dumps(peak.comments)).decode("utf-8"), - "artificialEICLShift": peak.artificialEICLShift, - }, - ) - - db_con.insert_row( - "allChromPeaks", - { - "id": peak.id, - "tracer": tracerID, - "eicID": peak.eicID, - "mz": peak.mz, - "lmz": peak.lmz, - "tmz": peak.tmz, - "xcount": peak.correctedXCount, - "xcountId": peak.xCount, - "Loading": peak.loading, - "ionMode": ionMode, - "NPeakCenter": peak.NPeakCenter, - "NPeakCenterMin": peak.NPeakCenterMin, - "NPeakScale": peak.NPeakScale, - "NSNR": peak.NSNR, - "NPeakArea": peak.NPeakArea, - "NPeakAbundance": peak.NPeakAbundance, - "NBorderLeft": peak.NBorderLeft, - "NBorderRight": peak.NBorderRight, - "LPeakCenter": peak.LPeakCenter, - "LPeakCenterMin": peak.LPeakCenterMin, - "LPeakScale": peak.LPeakScale, - "LSNR": peak.LSNR, - "LPeakArea": peak.LPeakArea, - "LPeakAbundance": peak.LPeakAbundance, - "LBorderLeft": peak.LBorderLeft, - "LBorderRight": peak.LBorderRight, - "N_startRT": peak.N_startRT, - "N_endRT": peak.N_endRT, - "L_startRT": peak.L_startRT, - "L_endRT": peak.L_endRT, - "peaksCorr": peak.peaksCorr, - "heteroAtoms": "", - "adducts": "", - "heteroAtomsFeaturePairs": "", - "assignedMZs": len(peak.assignedMZs), - "fDesc": base64.b64encode(dumps([])).decode("utf-8"), - "peaksRatio": peak.peaksRatio, - "peaksRatioMp1": peak.peaksRatioMp1, - "peaksRatioMPm1": peak.peaksRatioMPm1, - "peakType": "patternFound", - "comments": base64.b64encode(dumps(peak.comments)).decode("utf-8"), - "artificialEICLShift": peak.artificialEICLShift, - }, - ) - - chromPeaks.append(peak) - self.curPeakId = self.curPeakId + 1 - except Exception as e: - print("Error processing mzbin with meanmz %f: %s" % (meanmz, str(e))) - traceback.print_exc() - - self.curEICId = self.curEICId + 1 - db_con.commit() - db_con.close() - return chromPeaks - - # data processing step 4: remove those feature pairs, which represent incorrect pairings of - # isotoplogs of either the native or the labelled or both ions. Such incorrect pairings always have - # and increased mz value and/or a decreased number of labelled carbon atoms. Such identified - # incorrect pairings are then removed from the database and thus the processing results - def removeFalsePositiveFeaturePairsAndUpdateDB(self, chromPeaks, reportFunction=None): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - todel = {} - - # iterate over all detected feature pairs and compare those - # if the a) originate from the same ionisation mode - # and b) if they are not the same feature pair - for a in range(0, len(chromPeaks)): - if reportFunction is not None: - reportFunction( - 1.0 * a / len(chromPeaks), - "%d feature pairs remaining" % (len(chromPeaks) - a), - ) - - peakA = chromPeaks[a] - for b in range(0, len(chromPeaks)): - peakB = chromPeaks[b] - if a != b and peakA.ionMode == peakB.ionMode and (peakA.mz < peakB.mz or abs(peakA.mz - peakB.mz) <= (peakA.mz * 2.5 * self.ppm / 1000000.0)): - # not same feature pair but same ionisation mode - - if abs(peakA.NPeakCenter - peakB.NPeakCenter) <= self.peakCenterError: - # same retention time - if abs(peakA.mz - peakB.mz) <= (peakA.mz * 2.5 * self.ppm / 1000000.0): - # same mz value - if isinstance(peakA.xCount, float) and (peakB.xCount - peakA.xCount) == 2 and peakB.loading == peakA.loading and ((peakB.LPeakArea / peakA.LPeakArea) <= 0.1): - # incorrect matching of 18O atoms detected - # e.g. a and b have 869.4153; a has C39 and b has C41; peaksRatio(a)=1.46, peaksRatio(b)=43.48 - # --> 1.46/43.48~0.0335 (which is in good agreement with On) and thus b has to be removed - if b not in todel.keys(): - todel[b] = [] - todel[b].append("incorrectly matched hetero atoms (most likely O) with " + str(peakA.mz) + " " + str(peakA.xCount)) - elif isinstance(peakA.xCount, float) and (peakB.xCount - peakA.xCount) in [1, 2, 3] and peakB.loading == peakA.loading: - # different number of 1 atoms (peakA has less than peakB) - if a not in todel.keys(): - todel[a] = [] - todel[a].append("same mz but reduced number of carbon atoms with " + str(peakB.mz) + " " + str(peakB.xCount)) - - if isinstance(peakA.xCount, float) and (peakA.xCount * 2) == peakB.xCount and peakA.loading == peakB.loading: - # a is intermediate pairing of polymer - if a not in todel.keys(): - todel[a] = [] - todel[a].append("polymer mismatch with half the number of carbon atoms with " + str(peakB.mz) + " " + str(peakB.xCount)) - - if isinstance(peakA.xCount, float) and (peakA.xCount * 2) == peakB.xCount and peakA.loading == 1 and peakB.loading == 2: - # a is intermediate pairing of polymer - if a not in todel.keys(): - todel[a] = [] - todel[a].append("singly charged mismatch with half the number of carbon atoms with " + str(peakB.mz) + " " + str(peakB.xCount)) - - if isinstance(peakA.xCount, float) and 0 <= (peakB.xCount - peakA.xCount * 3) <= 2 and peakA.loading == 1 and peakB.loading == 3: - # a is intermediate pairing of polymer - if a not in todel.keys(): - todel[a] = [] - todel[a].append("singly charged mismatch with third the number of carbon atoms with " + str(peakB.mz) + " " + str(peakB.xCount)) - - elif abs(peakB.mz - peakA.mz - 1.00335 / peakA.loading) <= (peakA.mz * 2.5 * self.ppm / 1000000.0) and peakB.xCount == peakA.xCount and peakB.NPeakArea < 2 * peakA.NPeakArea: - ## increased m/z value by one carbon atom, but the same number of labeling atoms ## happens quite often with 15N-labeled metabolites e.g. 415.21374 and 415.7154 or 414.6978 and 415.19966 - if b not in todel.keys(): - todel[b] = [] - todel[b].append("increased m/z by one labeling atoms while the number of labeled atoms is identical." + str(peakA.mz)) - - else: - for i in [1, 2, 3]: - if peakA.loading == peakB.loading and abs(peakB.mz - peakA.mz - i * self.xOffset / peakA.loading) <= peakA.mz * 2.5 * self.ppm / 1000000.0 and isinstance(peakA.xCount, float) and (peakA.xCount - peakB.xCount) in [1, 2, 3]: - # b has an mz offset and a reduced number of carbon atoms (by one labelling atom) - if b not in todel.keys(): - todel[b] = [] - todel[b].append("increased mz (by %d) and reduced number of labeling atoms (by %d) with " % (i, peakA.xCount - peakB.xCount) + str(peakA.mz) + " " + str(peakA.xCount)) - elif peakA.loading == peakB.loading and abs(peakB.mz - peakA.mz - i * 1.00335 / peakA.loading) <= peakA.mz * 2.5 * self.ppm / 1000000.0 and peakA.xCount == peakB.xCount and peakB.NPeakArea <= peakA.NPeakArea * 0.1: - # b has an mz offset and a reduced number of carbon atoms (by one labelling atom) - if b not in todel.keys(): - todel[b] = [] - todel[b].append("increased mz (by %d 13C) and same number of labeling atoms with " % (i) + str(peakA.mz) + " " + str(peakA.xCount)) - - for a in range(0, len(chromPeaks)): - for b in range(0, len(chromPeaks)): - peakA = chromPeaks[a] - peakB = chromPeaks[b] - if a != b and peakA.ionMode == peakB.ionMode: - if abs(peakA.NPeakCenter - peakB.NPeakCenter) <= self.peakCenterError: - # same chrom peak - if abs(peakA.mz - peakB.mz - (peakA.lmz - peakA.mz)) <= peakA.mz * 2 * self.ppm / 1000000.0 and ((isinstance(peakA.xCount, float) and abs(peakA.xCount * 2 - peakB.xCount) <= 1) or (peakA.xCount == peakB.xCount)): - if a not in todel.keys(): - todel[a] = [] - todel[a].append("dimer (1)" + str(peakB.mz) + " " + str(peakB.xCount)) - delet = [] - for x in todel.keys(): - delet.append(x) - delet.sort() - delet.reverse() - for dele in delet: - cp = chromPeaks.pop(dele) - # Delete from chromPeaks - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].filter(pl.col("id") != cp.id) - # Update allChromPeaks comment - db_con.tables["allChromPeaks"] = db_con.tables["allChromPeaks"].with_columns(pl.when(pl.col("id") == cp.id).then(pl.lit(",".join(todel[dele]))).otherwise(pl.col("comment")).alias("comment")) - - # Delete XICs not in chromPeaks - valid_eic_ids = db_con.tables["chromPeaks"]["eicID"].unique().to_list() - db_con.tables["XICs"] = db_con.tables["XICs"].filter(pl.col("id").is_in(valid_eic_ids)) - - db_con.commit() - db_con.close() - - # Isotopolog mass differences (monoisotopic, in Da) for natural isotopes - _ISOTOPOLOG_MASS_DIFFS = { - "13C": 1.003355, - "15N": 0.997035, - "34S": 1.995796, - "54Fe": -1.995328, - "37Cl": 1.997050, - "D": 1.006277, - "18O": 2.004244, - } - - _M_ISOTOPOLOG_OFFSETS = [ - ("M-13C2", -2 * 1.003355), - ("M-13C", -1 * 1.003355), - ("M+13C", +1 * 1.003355), - ("M+13C2", +2 * 1.003355), - ("M+15N", +0.997035), - ("M+34S", +1.995796), - ("M-54Fe", -1.995328), - ("M+37Cl", +1.997050), - ] - - _MP_ISOTOPOLOG_OFFSETS = [ - ("Mp-13C2", -2 * 1.003355), - ("Mp-13C", -1 * 1.003355), - ("Mp+13C", +1 * 1.003355), - ("Mp+13C2", +2 * 1.003355), - ("Mp+15N", +0.997035), - ("Mp+34S", +1.995796), - ("Mp-54Fe", -1.995328), - ("Mp+37Cl", +1.997050), - ("Mp+D", +1.006277), - ("Mp+18O", +2.004244), - ] - - def calculateIsotopologRatiosForFeaturePairs(self, chromPeaks, mzxml, reportFunction=None): - """For each detected feature pair, extract EICs for isotopologs of M and M', - calculate peak area ratios within the respective peak boundaries, and store - the results as a dictionary in peak.isotopologRatios. - - All M-based ratios are relative to the M peak area; all M'-based ratios are - relative to the M' peak area. - """ - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - def _peak_area(eic, times, lb, rb): - rb = min(rb, len(eic) - 1) - lb = max(lb, 0) - if lb >= rb: - return 0.0 - return float(np.trapz(eic[lb : rb + 1], times[lb : rb + 1])) - - for i, peak in enumerate(chromPeaks): - if reportFunction is not None: - reportFunction(1.0 * i / len(chromPeaks), "%d features remaining" % (len(chromPeaks) - i)) - - scanEvent = self.positiveScanEvent if peak.ionMode == "+" else self.negativeScanEvent - loading = peak.loading - - lb_N = max(0, peak.NPeakCenter - int(peak.NBorderLeft)) - rb_N = peak.NPeakCenter + int(peak.NBorderRight) - lb_L = max(0, peak.LPeakCenter - int(peak.LBorderLeft)) - rb_L = peak.LPeakCenter + int(peak.LBorderRight) - - eic_M, times_M, _, _ = mzxml.getEIC(peak.mz, self.chromPeakPPM, filterLine=scanEvent) - eic_Mp, times_Mp, _, _ = mzxml.getEIC(peak.lmz, self.chromPeakPPM, filterLine=scanEvent) - eic_M = np.asarray(eic_M, dtype=np.float64) - eic_Mp = np.asarray(eic_Mp, dtype=np.float64) - times_M = np.asarray(times_M, dtype=np.float64) - times_Mp = np.asarray(times_Mp, dtype=np.float64) - - area_M = _peak_area(eic_M, times_M, lb_N, rb_N) - area_Mp = _peak_area(eic_Mp, times_Mp, lb_L, rb_L) - - isotopolog_ratios = {} - - for label, offset in self._M_ISOTOPOLOG_OFFSETS: - target_mz = peak.mz + offset / loading - eic_iso, times_iso, _, _ = mzxml.getEIC(target_mz, self.chromPeakPPM, filterLine=scanEvent) - eic_iso = np.asarray(eic_iso, dtype=np.float64) - times_iso = np.asarray(times_iso, dtype=np.float64) - area_iso = _peak_area(eic_iso, times_iso, lb_N, rb_N) - isotopolog_ratios[label] = area_iso / area_M if area_M > 0 else 0.0 - - for label, offset in self._MP_ISOTOPOLOG_OFFSETS: - target_mz = peak.lmz + offset / loading - eic_iso, times_iso, _, _ = mzxml.getEIC(target_mz, self.chromPeakPPM, filterLine=scanEvent) - eic_iso = np.asarray(eic_iso, dtype=np.float64) - times_iso = np.asarray(times_iso, dtype=np.float64) - area_iso = _peak_area(eic_iso, times_iso, lb_L, rb_L) - isotopolog_ratios[label] = area_iso / area_Mp if area_Mp > 0 else 0.0 - - peak.isotopologRatios = isotopolog_ratios - - for peak in chromPeaks: - encoded = base64.b64encode(dumps(peak.isotopologRatios)).decode("utf-8") - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].with_columns(pl.when(pl.col("id") == peak.id).then(pl.lit(encoded)).otherwise(pl.col("isotopologRatios")).alias("isotopologRatios")) - db_con.tables["allChromPeaks"] = db_con.tables["allChromPeaks"].with_columns(pl.when(pl.col("id") == peak.id).then(pl.lit(encoded)).otherwise(pl.col("isotopologRatios")).alias("isotopologRatios")) - - self.printMessage("Isotopolog ratio calculation done.", type="info") - db_con.commit() - db_con.close() - - # data processing step 5: in full metabolome labeling experiments hetero atoms (e.g. S, Cl) may - # show characterisitc isotope patterns on the labeled metabolite ion side. There, these peaks may not be - # dominated by the usually much more abundant carbon isotopes and can thus be easier seen. However, for - # low abundant metabolite ions these isotope peaks may not be present at all. The search is performed - # on a MS scan level and does not directly use the chromatographic information (no chromatographic - # peak picking is performed) - def annotateFeaturePairs(self, chromPeaks, mzxml, tracer, reportFunction=None): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - self.postMessageToProgressWrapper("text", "%s: Annotating feature pairs" % tracer.name) - for i in range(0, len(chromPeaks)): - if reportFunction is not None: - reportFunction( - 1.0 * i / len(chromPeaks), - "%d features remaining" % (len(chromPeaks) - i), - ) - - peak = chromPeaks[i] - - ## Annotate hetero atoms - for pIso in self.heteroAtoms: - pIsotope = self.heteroAtoms[pIso] - - mz = 0 - if pIsotope.mzOffset < 0: - mz = peak.mz + pIsotope.mzOffset / peak.loading # delta m/z is negative, therefore this decreases the search m/z - else: - mz = peak.lmz + pIsotope.mzOffset / peak.loading # delta m/z is positive - - refMz = 0 - if pIsotope.mzOffset < 0: - refMz = peak.mz - else: - refMz = peak.lmz - - scanEvent = "" - if peak.ionMode == "+": - scanEvent = self.positiveScanEvent - elif peak.ionMode == "-": - scanEvent = self.negativeScanEvent - - for haCount in range(pIsotope.minCount, pIsotope.maxCount + 1): - if haCount == 0: - continue - - for curScanNum in range( - int( - max( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ) - ), - int( - min( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - ) - + 1, - ): - scan = mzxml.getIthMS1Scan(curScanNum, scanEvent) - if scan is not None: - mzBounds = scan.findMZ(refMz, self.ppm) - mzBounds = scan.getMostIntensePeak(mzBounds[0], mzBounds[1]) - if mzBounds != -1: - peakIntensity = scan.intensity_list[mzBounds] - - isoBounds = scan.findMZ(mz, self.ppm) - isoBounds = scan.getMostIntensePeak(isoBounds[0], isoBounds[1]) - if isoBounds != -1: - isoIntensity = scan.intensity_list[isoBounds] - - relativeIntensity = isoIntensity / peakIntensity - if abs(relativeIntensity - pIsotope.relativeAbundance * haCount) < self.hAIntensityError: - if pIso not in peak.heteroIsotopologues: - peak.heteroIsotopologues[pIso] = {} - if haCount not in peak.heteroIsotopologues[pIso]: - peak.heteroIsotopologues[pIso][haCount] = [] - peak.heteroIsotopologues[pIso][haCount].append( - ( - scan.id, - relativeIntensity, - pIsotope.relativeAbundance * haCount, - ) - ) - - rmHIso = [] - for hI in peak.heteroIsotopologues: - for haCount in peak.heteroIsotopologues[hI]: - if len(peak.heteroIsotopologues[hI][haCount]) < self.hAMinScans: - rmHIso.append((hI, haCount)) - - for rmHI, rmHACount in rmHIso: - del peak.heteroIsotopologues[rmHI][rmHACount] - rmHIso = [] - for hi in peak.heteroIsotopologues: - if len(peak.heteroIsotopologues[hi]) == 0: - rmHIso.append(hi) - for rmHI in rmHIso: - del peak.heteroIsotopologues[rmHI] - - self.getMostLikelyHeteroIsotope(peak.heteroIsotopologues) - - ## Annotate isotopolog ratios - - def findRatiosForMZs(mzFrom, mzTo, fromScan, toScan, mzxml, scanEvent, ppm): - isoRatios = [] - for curScanNum in range(fromScan, toScan): - scan = mzxml.getIthMS1Scan(curScanNum, scanEvent) - if scan is not None: - mzBounds = scan.findMZ(mzTo, ppm) - mzBounds = scan.getMostIntensePeak(mzBounds[0], mzBounds[1]) - if mzBounds != -1: - peakIntensity = scan.intensity_list[mzBounds] - - isoBounds = scan.findMZ(mzFrom, ppm) - isoBounds = scan.getMostIntensePeak(isoBounds[0], isoBounds[1]) - - if isoBounds != -1: - isoPeakIntensity = scan.intensity_list[isoBounds] - - isoRatios.append( - Bunch( - ratio=isoPeakIntensity / peakIntensity, - refInt=peakIntensity, - ) - ) - - return isoRatios - - peak.isotopeRatios = [] - for i in range(1, self.calcIsoRatioNative + 1): - isoRatios = findRatiosForMZs( - peak.mz + 1.00335484 * i / peak.loading, - peak.mz, - int( - max( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ) - ), - int( - min( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - ) - + 1, - mzxml, - scanEvent, - self.ppm, - ) - observedRatioMean = weightedMean([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - observedRatioSD = weightedSd([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - if isinstance(peak.xCount, str): - theoreticalRatio = -1 - else: - theoreticalRatio = getNormRatio(self.purityN, peak.xCount, i) - peak.isotopeRatios.append( - Bunch( - type="native", - subs=i, - observedRatioMean=observedRatioMean, - observedRatioSD=observedRatioSD, - theoreticalRatio=theoreticalRatio, - ) - ) - - for i in range(-1, self.calcIsoRatioLabelled - 1, -1): - isoRatios = findRatiosForMZs( - peak.lmz + 1.00335484 * i / peak.loading, - peak.lmz, - int( - max( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ) - ), - int( - min( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - ) - + 1, - mzxml, - scanEvent, - self.ppm, - ) - observedRatioMean = weightedMean([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - observedRatioSD = weightedSd([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - if isinstance(peak.xCount, str): - theoreticalRatio = -1 - else: - theoreticalRatio = getNormRatio(self.purityL, peak.xCount, abs(i)) - peak.isotopeRatios.append( - Bunch( - type="labelled", - subs=abs(i), - observedRatioMean=observedRatioMean, - observedRatioSD=observedRatioSD, - theoreticalRatio=theoreticalRatio, - ) - ) - if self.metabolisationExperiment: - for i in range(1, self.calcIsoRatioMoiety + 1): - isoRatios = findRatiosForMZs( - peak.lmz + i * 1.00335484 / peak.loading, - peak.lmz, - int( - max( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ) - ), - int( - min( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - ) - + 1, - mzxml, - scanEvent, - self.ppm, - ) - observedRatioMean = weightedMean([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - observedRatioSD = weightedSd([t.ratio for t in isoRatios], [t.refInt for t in isoRatios]) - peak.isotopeRatios.append( - Bunch( - type="moiety", - subs=i, - observedRatioMean=observedRatioMean, - observedRatioSD=observedRatioSD, - theoreticalRatio=0, - ) - ) - - def findMZDifferenceRelativeToXnForMZs(mzFrom, mzTo, tmz, fromScan, toScan, mzxml, scanEvent, ppm): - diffs = [] - for curScanNum in range(fromScan, toScan): - scan = mzxml.getIthMS1Scan(curScanNum, scanEvent) - if scan is not None: - mzBounds = scan.findMZ(mzTo, ppm) - mzBounds = scan.getMostIntensePeak(mzBounds[0], mzBounds[1]) - if mzBounds != -1: - peakMZ = scan.mz_list[mzBounds] - - refBounds = scan.findMZ(mzFrom, ppm) - refBounds = scan.getMostIntensePeak(refBounds[0], refBounds[1]) - - if refBounds != -1: - isoPeakMZ = scan.mz_list[refBounds] - - diffs.append((abs(isoPeakMZ - peakMZ) - tmz) * 1000000.0 / mzFrom) - - return diffs - - diffs = findMZDifferenceRelativeToXnForMZs( - peak.mz, - peak.lmz, - peak.lmz - peak.mz, - int( - max( - peak.NPeakCenter - peak.NBorderLeft, - peak.LPeakCenter - peak.LBorderLeft, - ) - ), - int( - min( - peak.NPeakCenter + peak.NBorderRight, - peak.LPeakCenter + peak.LBorderRight, - ) - ) - + 1, - mzxml, - scanEvent, - self.ppm, - ) - peak.mzDiffErrors = Bunch(mean=mean(diffs), sd=sd(diffs), vals=diffs) - - # Batch update using Polars for better performance - updates_dict = {"heteroAtoms": {}, "isotopesRatios": {}, "mzDiffErrors": {}} - - for i in range(len(chromPeaks)): - peak = chromPeaks[i] - updates_dict["heteroAtoms"][peak.id] = base64.b64encode(dumps(peak.heteroIsotopologues)).decode("utf-8") - updates_dict["isotopesRatios"][peak.id] = base64.b64encode(dumps(peak.isotopeRatios)).decode("utf-8") - updates_dict["mzDiffErrors"][peak.id] = base64.b64encode(dumps(peak.mzDiffErrors)).decode("utf-8") - - # Update chromPeaks - for col_name, id_val_map in updates_dict.items(): - for peak_id, value in id_val_map.items(): - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].with_columns(pl.when(pl.col("id") == peak_id).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)) - db_con.tables["allChromPeaks"] = db_con.tables["allChromPeaks"].with_columns(pl.when(pl.col("id") == peak_id).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)) - - self.printMessage("%s: Annotating feature pairs done." % tracer.name, type="info") - - db_con.commit() - db_con.close() - - # annotate a metabolite group (consisting of ChromPeakPair instances) with the defined - # hetero atoms based on detected feature pairs - def annotateFeaturePairsWithHeteroAtoms(self, group, peaksInGroup): - # iterate all peaks pairwise to find different adducts of the metabolite - for pa in group: - peakA = peaksInGroup[pa] - - for pb in group: - peakB = peaksInGroup[pb] - - # peakA shall always have the lower mass - if peakA.mz < peakB.mz and peakA.xCount == peakB.xCount: - ## check, if it could be a hetero atom - for pIso in self.heteroAtoms: - pIsotope = self.heteroAtoms[pIso] - - bestFit = None - bestFitRatio = 100000 - bestFitID = None - - if abs(peakB.mz - peakA.mz - pIsotope.mzOffset) <= (max(peakA.mz, peakB.mz) * 2.5 * self.ppm / 1000000.0): - if pIsotope.mzOffset > 0: - ratio = peakB.LPeakArea / peakA.LPeakArea - else: - ratio = peakB.NPeakArea / peakA.NPeakArea - - for nHAtoms in range(pIsotope.minCount, pIsotope.maxCount + 1): - if abs(ratio - nHAtoms * pIsotope.relativeAbundance) <= self.hAIntensityError: - if abs(ratio - nHAtoms * pIsotope.relativeAbundance) < bestFitRatio: - bestFitRatio = abs(ratio - nHAtoms * pIsotope.relativeAbundance) - bestFit = Bunch(pIso=pIso, nHAtoms=nHAtoms) - bestFitID = pb - - if bestFit is not None: - peakB = peaksInGroup[bestFitID] - # peakA.heteroAtomsFeaturePairs.append(Bunch(name="M_%s%d"%(pIso, bestFit), partnerAdd="_%s%d"%(pIso, bestFit), toPeak=pb)) - peakB.heteroAtomsFeaturePairs.append( - Bunch( - name="_%s%d" % (bestFit.pIso, bestFit.nHAtoms), - partnerAdd="M_%s%d" % (bestFit.pIso, bestFit.nHAtoms), - toPeak=pa, - ) - ) - - # annotate a metabolite group (consisting of ChromPeakPair instances) with the defined - # adducts and generate possible in-source fragments. Remove inconsistencies - # in the form of impossible adduct combinations (e.g. [M+H]+ and [M+Na]+ for the same ion) - def annotateChromPeaks(self, group, peaksInGroup): - for pe in group: - peak = peaksInGroup[pe] - - if not hasattr(peak, "fDesc"): - setattr(peak, "fDesc", []) - if not hasattr(peak, "adducts"): - setattr(peak, "adducts", []) - if not hasattr(peak, "Ms"): - setattr(peak, "Ms", []) - - if len(group) <= 40 or True: - self.annotateFeaturePairsWithHeteroAtoms(group, peaksInGroup) - for pa in group: - peakA = peaksInGroup[pa] - peakA.adducts.extend(peakA.heteroAtomsFeaturePairs) - - fT = formulaTools() - - # prepare adducts list - addPol = {} - addPol["+"] = [] - addPol["-"] = [] - adductsDict = {} - for adduct in self.adducts: - adductsDict[adduct.name] = adduct - if adduct.polarity != "": - addPol[adduct.polarity].append(adduct) - adducts = addPol - adducts["+"] = sorted(adducts["+"], key=lambda x: x.mzoffset) - adducts["-"] = sorted(adducts["-"], key=lambda x: x.mzoffset) - - # 1. iterate all peaks pairwise to find different adducts of the metabolite - for pa in group: - peakA = peaksInGroup[pa] - for pb in group: - peakB = peaksInGroup[pb] - - # peakA shall always have the lower mass - if peakA.mz < peakB.mz and (len(peakB.heteroAtomsFeaturePairs) == 0 or not all([s.name.startswith("_") for s in peakB.heteroAtomsFeaturePairs])): - # search for different adduct combinations - # different adducts must have the same number of labelled atoms - if peakA.xCount == peakB.xCount: - # check, if it could be some kind of adduct combination - for adA in adducts[peakA.ionMode]: - for adB in adducts[peakB.ionMode]: - if adA.mCount == 1 and adB.mCount == 1 and adA.mzoffset < adB.mzoffset: - if ( - peakA.ionMode == "-" - and peakB.ionMode == "+" - and peakA.loading == peakB.loading - and abs(abs(adB.mzoffset - adA.mzoffset) - 2 * 1.007276) <= (max(peakA.mz, peakB.mz) * 2.5 * self.ppm / 1000000.0) - and abs(peakB.mz - peakA.mz - 2 * 1.007276 / peakA.loading) <= (max(peakA.mz, peakB.mz) * 2.5 * self.ppm / 1000000.0) - ): - peakA.adducts.append( - Bunch( - name="[M-H]-", - partnerAdd="[M+H]+", - toPeak=pb, - ) - ) - peakB.adducts.append( - Bunch( - name="[M+H]+", - partnerAdd="[M-H]-", - toPeak=pa, - ) - ) - - elif peakA.loading == adA.charge and peakB.loading == adB.charge and abs((peakA.mz - adA.mzoffset) / adA.mCount * peakA.loading - (peakB.mz - adB.mzoffset) / adB.mCount * peakB.loading) <= (max(peakA.mz, peakB.mz) * 2.5 * self.ppm / 1000000.0): - peakA.adducts.append( - Bunch( - name=adA.name, - partnerAdd=adB.name, - toPeak=pb, - ) - ) - peakB.adducts.append( - Bunch( - name=adB.name, - partnerAdd=adA.name, - toPeak=pa, - ) - ) - - # search for adducts of the form [2M+XX]+- - elif peakA.xCount * 2 == peakB.xCount: - for adA in adducts[peakA.ionMode]: - for adB in adducts[peakB.ionMode]: - if adA.mCount == 1 and adB.mCount == 2: - if peakA.loading == adA.charge and peakB.loading == adB.charge and abs((peakA.mz - adA.mzoffset) / adA.mCount * peakA.loading - (peakB.mz - adB.mzoffset) / adB.mCount * peakB.loading) <= (max(peakA.mz, peakB.mz) * 2.5 * self.ppm / 1000000.0): - peakA.adducts.append( - Bunch( - name=adA.name, - partnerAdd=adB.name, - toPeak=pb, - ) - ) - peakB.adducts.append( - Bunch( - name=adB.name, - partnerAdd=adA.name, - toPeak=pa, - ) - ) - - def isAdductPairPresent(pA, adductAName, adductsA, pB, adductBName, adductsB, checkPartners=True): - aFound = False - for add in adductsA: - if add.name == adductAName and (not checkPartners or (add.partnerAdd == adductBName and add.toPeak == pB)): - aFound = True - bFound = False - for add in adductsB: - if add.name == adductBName and (not checkPartners or (add.partnerAdd == adductAName and add.toPeak == pA)): - bFound = True - return aFound and bFound - - def removeAdductFromFeaturePair(pA, adductAName, adductsA): - aFound = [] - for i, add in enumerate(adductsA): - if add.name == adductAName: - aFound.append(i) - - aFound = reversed(sorted(list(set(aFound)))) - for i in aFound: - del adductsA[i] - - def removeAdductPair(pA, adductAName, adductsA, pB, adductBName, adductsB): - aFound = [] - for i, add in enumerate(adductsA): - if add.name == adductAName and add.partnerAdd == adductBName and add.toPeak == pB: - aFound.append(i) - bFound = [] - for i, add in enumerate(adductsB): - if add.name == adductBName and add.partnerAdd == adductAName and add.toPeak == pA: - bFound.append(i) - - aFound = reversed(sorted(list(set(aFound)))) - for i in aFound: - del adductsA[i] - bFound = reversed(sorted(list(set(bFound)))) - for i in bFound: - del adductsB[i] - - # 2. remove incorrect annotations from feature pairs e.g. A: [M+H]+ and [M+Na]+ with B: [M+Na]+ and [M+2Na-H]+ - for p in group: - peak = peaksInGroup[p] - peak.adducts = list(set(peak.adducts)) - - for pa in group: - peakA = peaksInGroup[pa] - if len(peakA.adducts) > 0: - for pb in group: - peakB = peaksInGroup[pb] - if len(peakB.adducts) > 0: - if peakA.mz < peakB.mz: - for pc in group: - peakC = peaksInGroup[pc] - if len(peakC.adducts) > 0: - if ( - isAdductPairPresent( - pa, - "[M+H]+", - peakA.adducts, - pb, - "[M+Na]+", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+Na]+", - peakA.adducts, - pb, - "[M+2Na-H]+", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+H]+", - peakA.adducts, - pc, - "[M+2Na-H]+", - peakC.adducts, - ) - and isAdductPairPresent( - pb, - "[M+H]+", - peakB.adducts, - pc, - "[M+Na]+", - peakC.adducts, - ) - and isAdductPairPresent( - pb, - "[M+Na]+", - peakB.adducts, - pc, - "[M+2Na-H]+", - peakC.adducts, - ) - and peakA.mz < peakB.mz < peakC.mz - ): - removeAdductPair( - pa, - "[M+Na]+", - peakA.adducts, - pb, - "[M+2Na-H]+", - peakB.adducts, - ) - removeAdductPair( - pb, - "[M+H]+", - peakB.adducts, - pc, - "[M+Na]+", - peakC.adducts, - ) - - if ( - isAdductPairPresent( - pa, - "[M+H]+", - peakA.adducts, - pb, - "[M+K]+", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+K]+", - peakA.adducts, - pb, - "[M+2K-H]+", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+H]+", - peakA.adducts, - pc, - "[M+2K-H]+", - peakC.adducts, - ) - and isAdductPairPresent( - pb, - "[M+H]+", - peakB.adducts, - pc, - "[M+K]+", - peakC.adducts, - ) - and isAdductPairPresent( - pb, - "[M+K]+", - peakB.adducts, - pc, - "[M+2K-H]+", - peakC.adducts, - ) - and peakA.mz < peakB.mz < peakC.mz - ): - removeAdductPair( - pa, - "[M+K]+", - peakA.adducts, - pb, - "[M+2K-H]+", - peakB.adducts, - ) - removeAdductPair( - pb, - "[M+H]+", - peakB.adducts, - pc, - "[M+K]+", - peakC.adducts, - ) - - if ( - isAdductPairPresent( - pa, - "[M+2H]++", - peakA.adducts, - pb, - "[M+H+Na]++", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+H+Na]++", - peakA.adducts, - pb, - "[M+2Na]++", - peakB.adducts, - ) - and peakA.mz < peakB.mz - ): - removeAdductPair( - pa, - "[M+H+Na]++", - peakA.adducts, - pb, - "[M+2Na]++", - peakB.adducts, - ) - - if ( - isAdductPairPresent( - pa, - "[M+2H]++", - peakA.adducts, - pb, - "[M+H+K]++", - peakB.adducts, - ) - and isAdductPairPresent( - pa, - "[M+H+K]++", - peakA.adducts, - pb, - "[M+2K]++", - peakB.adducts, - ) - and peakA.mz < peakB.mz - ): - removeAdductPair( - pa, - "[M+H+Na]++", - peakA.adducts, - pb, - "[M+2Na]++", - peakB.adducts, - ) - - for pa in group: - peakA = peaksInGroup[pa] - if len(peakA.adducts) > 0: - if isAdductPairPresent( - pa, - "[M+H]+", - peakA.adducts, - pa, - "[2M+H]+", - peakA.adducts, - checkPartners=False, - ): - removeAdductFromFeaturePair(pa, "[M+H]+", peakA.adducts) - if isAdductPairPresent( - pa, - "[M+NH4]+", - peakA.adducts, - pa, - "[2M+NH4]+", - peakA.adducts, - checkPartners=False, - ): - removeAdductFromFeaturePair(pa, "[M+NH4]+", peakA.adducts) - if isAdductPairPresent( - pa, - "[M+Na]+", - peakA.adducts, - pa, - "[2M+Na]+", - peakA.adducts, - checkPartners=False, - ): - removeAdductFromFeaturePair(pa, "[M+Na]+", peakA.adducts) - if isAdductPairPresent( - pa, - "[M+K]+", - peakA.adducts, - pa, - "[2M+K]+", - peakA.adducts, - checkPartners=False, - ): - removeAdductFromFeaturePair(pa, "[M+K]+", peakA.adducts) - - inSourceFragments = {} - - # 3. iterate all peaks pairwise to find common in-source fragments - for pa in group: - peakA = peaksInGroup[pa] - - for pb in group: - peakB = peaksInGroup[pb] - - # peakA shall always have the lower mass - if peakA.mz < peakB.mz and abs(peakA.mz - peakB.mz) <= 101: - mzDif = peakB.mz - peakA.mz - - # generate putative in-source fragments (using the labelled carbon atoms) - if len(self.elements) > 0: - elemDictReq = {} - for elem in self.elements: - elemDictReq[elem] = [ - self.elements[elem].weight, - self.elements[elem].numberValenzElectrons, - ] - t = SGRGenerator(atoms=elemDictReq) - - useAtoms = [] - if self.labellingElement in self.elements.keys(): - useAtoms.append(self.labellingElement) - for k in self.elements.keys(): - if k != self.labellingElement: - useAtoms.append(k) - else: - useAtoms = list(self.elements.keys()) - - try: - atomsRange = [] - if self.labellingElement in useAtoms: - if self.metabolisationExperiment: - elem = self.elements[self.labellingElement] - atomsRange.append( - [ - abs(peakA.xCount - peakB.xCount), - abs(peakA.xCount - peakB.xCount + elem.maxCount - elem.minCount), - ] - ) - else: - atomsRange.append(abs(peakA.xCount - peakB.xCount)) - - for elem in self.elements.keys(): - if elem != self.labellingElement: - elem = self.elements[elem] - atomsRange.append([elem.minCount, elem.maxCount]) - - corrFact = abs(peakB.mz - peakA.mz) - if corrFact <= 1: - corrFact = 1.0 - - pFs = t.findFormulas( - mzDif, - ppm=self.ppm * 2.0 * peakA.mz / corrFact, - useAtoms=useAtoms, - atomsRange=atomsRange, - fixed=self.labellingElement, - useSevenGoldenRules=False, - ) - - for pF in pFs: - if pa not in inSourceFragments.keys(): - inSourceFragments[pa] = {} - if pb not in inSourceFragments[pa].keys(): - inSourceFragments[pa][pb] = [] - - c = fT.parseFormula(pF) - mw = fT.calcMolWeight(c) - abs(abs(peakB.mz - peakA.mz) - mw) - sf = fT.flatToString(c, prettyPrintWithHTMLTags=False) - inSourceFragments[pa][pb].append("%.4f-%s" % (peakB.mz, sf)) - - except Exception as ex: - print(f"ERROR: generating in-source fragments failed: {ex}") - - if self.simplifyInSourceFragments: - for pa in group: - peakA = peaksInGroup[pa] - peakA.fDesc = [] - - for pb in group: - peakB = peaksInGroup[pb] - - for pc in group: - peakC = peaksInGroup[pc] - - if peakA.mz < peakC.mz < peakB.mz: - if pa in inSourceFragments.keys() and pb in inSourceFragments[pa].keys() and pc in inSourceFragments[pa].keys() and pc in inSourceFragments.keys() and pb in inSourceFragments[pc].keys(): - del inSourceFragments[pa][pc] - - for pa in group: - peakA = peaksInGroup[pa] - - if pa in inSourceFragments.keys(): - for pb in inSourceFragments[pa].keys(): - for inFrag in inSourceFragments[pa][pb]: - peakA.fDesc.append(inFrag) - - for pe in group: - peak = peaksInGroup[pe] - peak.fDesc = list(set(peak.fDesc)) - peak.adducts = list(set([a.name for a in peak.adducts])) - - if not hasattr(peak, "Ms"): - setattr(peak, "Ms", []) - - peak.Ms = [] - for assignedAdduct in peak.adducts: - if assignedAdduct in adductsDict.keys(): - peak.Ms.append((peak.mz - adductsDict[assignedAdduct].mzoffset) / adductsDict[assignedAdduct].mCount / peak.loading) - - # data processing step 6: convolute different feature pairs into feature groups using the chromatographic - # profiles of different metabolite ions - def groupFeaturePairsUntargetedAndWriteToDB(self, chromPeaks, mzxml, tracer, tracerID, reportFunction=None): - try: - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - nodes = {} - correlations = {} - - allPeaks = {} - for peak in chromPeaks: - nodes[peak.id] = [] - allPeaks[peak.id] = peak - peak.correlationsToOthers = [] - - # compare all detected feature pairs at approximately the same retention time - for piA in range(len(chromPeaks)): - peakA = chromPeaks[piA] - if reportFunction is not None: - reportFunction( - 0.7 * piA / len(chromPeaks), - "Matching features (%d remaining)" % (len(chromPeaks) - piA), - ) - - if peakA.id not in correlations.keys(): - correlations[peakA.id] = {} - - for peakB in chromPeaks: - if peakB.id not in correlations.keys(): - correlations[peakB.id] = {} - - if peakA.mz < peakB.mz: - if abs(peakA.NPeakCenter - peakB.NPeakCenter) < self.peakCenterError: - bmin = int( - max( - 0, - mean( - [ - peakA.NPeakCenter - 1 * peakA.NBorderLeft, - peakB.NPeakCenter - 1 * peakB.NBorderLeft, - peakA.LPeakCenter - 1 * peakA.LBorderLeft, - peakB.LPeakCenter - 1 * peakB.LBorderLeft, - ] - ), - ) - ) - bmax = int( - min( - len(peakB.NXICSmoothed) - 1, - mean( - [ - peakB.NPeakCenter + 1 * peakB.NBorderRight, - peakA.NPeakCenter + 1 * peakA.NBorderRight, - peakB.LPeakCenter + 1 * peakB.LBorderRight, - peakA.LPeakCenter + 1 * peakA.LBorderRight, - ] - ), - ) - ) - - pb = corr( - peakA.NXICSmoothed[bmin:bmax], - peakB.NXICSmoothed[bmin:bmax], - ) - - if str(pb) == "nan": - pb = -1 - - correlations[peakA.id][peakB.id] = pb - correlations[peakB.id][peakA.id] = pb - - silRatiosA = peakA.silRatios.silRatios - silRatiosB = peakB.silRatios.silRatios - - meanSilRatioA = weightedMean(silRatiosA, peakA.silRatios.peakNInts) - meanSilRatioB = weightedMean(silRatiosB, peakB.silRatios.peakNInts) - - silRatiosFold = 0 - silRatiosSD = 1 - - try: - silRatiosFold = max(meanSilRatioA, meanSilRatioB) / min(meanSilRatioA, meanSilRatioB) - silRatiosSD = weightedSd( - [abs(r - meanSilRatioA) / meanSilRatioA for r in silRatiosA if min(r, meanSilRatioA) > 0] + [abs(r - meanSilRatioB) / meanSilRatioB for r in silRatiosB if min(r, meanSilRatioB) > 0], - peakA.silRatios.peakNInts + peakB.silRatios.peakNInts, - ) - - # check for similar chromatographic peak profile and similar native to labeled ratio - if pb >= self.minCorrelation and silRatiosFold <= 1 + max(0.25, 3 * silRatiosSD): - nodes[peakA.id].append(peakB.id) - nodes[peakB.id].append(peakA.id) - - peakB.correlationsToOthers.append(peakA.id) - peakA.correlationsToOthers.append(peakB.id) - - except Exception as e: - logging.error("Error while convoluting two feature pairs, skipping.. (%s)" % str(e)) - - try: - db_con.insert_row("featurefeatures", {"fID1": peakA.id, "fID2": peakB.id, "corr": pb, "silRatioValue": silRatiosFold}) - except Exception as e: - logging.error("Error while convoluting two feature pairs, skipping.. (%s)" % str(e)) - db_con.insert_row("featurefeatures", {"fID1": peakA.id, "fID2": peakB.id, "corr": 0, "silRatioValue": 0}) - - self.postMessageToProgressWrapper("text", "%s: Convoluting feature groups" % tracer.name) - - for peak in chromPeaks: - delattr(peak, "NXIC") - delattr(peak, "LXIC") - delattr(peak, "times") - - for k in nodes.keys(): - uniq = [] - for u in nodes[k]: - if u not in uniq: - uniq.append(u) - nodes[k] = uniq - - # get subgraphs from the feature pair graph. Each subgraph represents one convoluted - # feature group - tGroups = getSubGraphs(nodes) - - def splitGroupWithHCA(tGroup, correlations): - # if 1 or two feature pairs are in a group, automatically use them (no further splitting possible) - if len(tGroup) <= 2: - return [tGroup] - - # construct 2-dimensional data matrix - data = [] - for tG1 in tGroup: - t = [] - for tG2 in tGroup: - if tG1 in correlations.keys() and tG2 in correlations[tG1].keys(): - t.append(correlations[tG1][tG2]) - elif tG1 == tG2: - t.append(1.0) - else: - t.append(0.0) - data.append(t) - - # calculate HCA tree using the correlations between all feature pairs - hc = HCA_general.HCA_generic() - tree = hc.generateTree(objs=data, ids=tGroup) - # hc.plotTree(tree) - # print("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-") - - # split the HCA tree in sub-clusters - def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): - if isinstance(tree, HCA_general.HCALeaf): - return False - elif isinstance(tree, HCA_general.HCAComposite): - corrsLKid = hca.link(tree.getLeftKid()) - corrs = hca.link(tree) - corrsRKid = hca.link(tree.getRightKid()) - - aboveThresholdLKid = sum([corr > corrThreshold for corr in corrsLKid]) - aboveThreshold = sum([corr > corrThreshold for corr in corrs]) - aboveThresholdRKid = sum([corr > corrThreshold for corr in corrsRKid]) - - # print(aboveThresholdLKid, aboveThreshold, aboveThresholdRKid) - - if (aboveThresholdLKid * 1.0 / len(corrs)) >= cutOffMinRatio and (aboveThreshold * 1.0 / len(corrs)) >= cutOffMinRatio and (aboveThresholdRKid * 1.0 / len(corrs)) >= cutOffMinRatio: - return False - else: - return True - - # subClusts=hc.splitTreeWithCallbackBottomUp(tree, - # CallBackMethod(_target=checkSubCluster, corrThreshold=self.minCorrelation, cutOffMinRatio=self.minCorrelationConnections).getRunMethod()) - subClusts = hc.splitTreeWithCallback( - tree, - CallBackMethod( - _target=checkSubCluster, - corrThreshold=self.minCorrelation, - cutOffMinRatio=self.minCorrelationConnections, - ).getRunMethod(), - recursive=False, - ) - - # convert the subclusters into arrays of feature pairs belonging to the same metabolite - return [[leaf.getID() for leaf in subClust.getLeaves()] for subClust in subClusts] - - groups = [] - done = 0 - for tGroup in tGroups: - # print("-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-") - cGroups = [tGroup] - while len(cGroups) > 0: - # print("HCA with", len(cGroups)) - gGroup = cGroups.pop(0) - - ## TODO optimize this code, it recalculates the computationally expensive HCA too often for a high number of features - if False and len(gGroup) > 100: - groups.append(gGroup) - continue - - sGroups = splitGroupWithHCA(gGroup, correlations) - # print("first group", len(gGroup), gGroup, "split into", sGroups) - - if len(sGroups) == 1: - groups.append(sGroups[0]) - else: - cGroups.extend(sGroups) - - done = done + 1 - self.postMessageToProgressWrapper( - "text", - "%s: Convoluting feature groups (%d/%d done)" % (tracer.name, done, len(tGroups)), - ) - - if True: - done = 0 - for gi in range(len(groups)): - group = groups[gi] - if reportFunction is not None: - reportFunction( - 0.7 + 0.3 * piA / len(chromPeaks), - "Searching for relationships (%d remaining)" % (len(groups) - gi), - ) - - # first, search for adduct relationships (same number of carbon atoms) in each convoluted - # feature group - - peaksInGroup = {} - for a in group: - peaksInGroup[a] = allPeaks[a] - - temp = [] - for j in range(len(peaksInGroup[a].correlationsToOthers)): - if peaksInGroup[a].correlationsToOthers[j] in group: - temp.append(peaksInGroup[a].correlationsToOthers[j]) - - peaksInGroup[a].correlationsToOthers = temp - - self.annotateChromPeaks(group, peaksInGroup) # store feature pair annotation in the database - - done = done + 1 - self.postMessageToProgressWrapper( - "text", - "%s: Annotating feature groups (%d/%d done)" % (tracer.name, done, len(groups)), - ) - - for peak in chromPeaks: - adds = countEntries(peak.adducts) - peak.adducts = list(adds.keys()) - - # Update chromPeaks - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].with_columns( - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.adducts)).decode("utf-8"))).otherwise(pl.col("adducts")).alias("adducts"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.fDesc)).decode("utf-8"))).otherwise(pl.col("fDesc")).alias("fDesc"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.correlationsToOthers)).decode("utf-8"))).otherwise(pl.col("correlationsToOthers")).alias("correlationsToOthers"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.heteroAtomsFeaturePairs)).decode("utf-8"))).otherwise(pl.col("heteroAtomsFeaturePairs")).alias("heteroAtomsFeaturePairs"), - ) - - # Update allChromPeaks - db_con.tables["allChromPeaks"] = db_con.tables["allChromPeaks"].with_columns( - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.adducts)).decode("utf-8"))).otherwise(pl.col("adducts")).alias("adducts"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.fDesc)).decode("utf-8"))).otherwise(pl.col("fDesc")).alias("fDesc"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.heteroAtomsFeaturePairs)).decode("utf-8"))).otherwise(pl.col("heteroAtomsFeaturePairs")).alias("heteroAtomsFeaturePairs"), - ) - - # store feature group in the database - for group in sorted( - groups, - key=lambda x: sum([allPeaks[p].NPeakCenterMin / 60.0 for p in x]) / len(x), - ): - db_con.insert_row("featureGroups", {"id": self.curFeatureGroupID, "featureName": "fg_%d" % self.curFeatureGroupID, "tracer": tracerID}) - groupMeanElutionIndex = 0 - - hasPos = False - hasNeg = False - - for p in sorted(group, key=lambda x: allPeaks[x].mz): - hasPos = hasPos or allPeaks[p].ionMode == "+" - hasNeg = hasNeg or allPeaks[p].ionMode == "-" - - groupMeanElutionIndex += allPeaks[p].NPeakCenter - - allPeaks[p].fGroupID = self.curFeatureGroupID - db_con.insert_row("featureGroupFeatures", {"fID": p, "fDesc": "", "fGroupID": self.curFeatureGroupID}) - - groupMeanElutionIndex = groupMeanElutionIndex / len(group) - - # store one positve and one negative ionisation mode MS scan in the database for - # later visualisation (one for each convoluted feature group) - if hasPos: - scan = mzxml.getIthMS1Scan(int(groupMeanElutionIndex), self.positiveScanEvent) - - db_con.insert_row("massspectrum", {"mID": self.curMassSpectrumID, "fgID": self.curFeatureGroupID, "time": scan.retention_time, "mzs": ";".join([str(u) for u in scan.mz_list]), "intensities": ";".join([str(u) for u in scan.intensity_list]), "ionMode": "+"}) - self.curMassSpectrumID += 1 - if hasNeg: - scan = mzxml.getIthMS1Scan(int(groupMeanElutionIndex), self.negativeScanEvent) - - db_con.insert_row("massspectrum", {"mID": self.curMassSpectrumID, "fgID": self.curFeatureGroupID, "time": scan.retention_time, "mzs": ";".join([str(u) for u in scan.mz_list]), "intensities": ";".join([str(u) for u in scan.intensity_list]), "ionMode": "-"}) - self.curMassSpectrumID += 1 - - self.curFeatureGroupID += 1 - - db_con.commit() - self.printMessage( - "%s: Feature grouping done. " % tracer.name + str(len(groups)) + " feature groups", - type="info", - ) - - db_con.commit() - db_con.close() - - except Exception as ex: - traceback.print_exc() - - self.printMessage("Error in %s: %s" % (self.file, str(ex)), type="error") - self.postMessageToProgressWrapper("failed", self.pID) - - # store one MS scan for each detected feature pair in the database - def writeMassSpectraToDB(self, chromPeaks, mzxml, reportFunction=None): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - massSpectraWrittenPos = {} - massSpectraWrittenNeg = {} - for pi in range(len(chromPeaks)): - peak = chromPeaks[pi] - if reportFunction is not None: - reportFunction( - 1.0 * pi / len(chromPeaks), - "%d feature pairs remaining" % (len(chromPeaks) - pi), - ) - - scanEvent = "" - iMode = "" - massSpectraWritten = None - if peak.ionMode == "+": - iMode = "+" - scanEvent = self.positiveScanEvent - massSpectraWritten = massSpectraWrittenPos - elif peak.ionMode == "-": - iMode = "-" - scanEvent = self.negativeScanEvent - massSpectraWritten = massSpectraWrittenNeg - - if peak.NPeakCenter not in massSpectraWritten.keys(): - scan = mzxml.getIthMS1Scan(peak.NPeakCenter, scanEvent) - - db_con.insert_row("massspectrum", {"mID": self.curMassSpectrumID, "fgID": -1, "time": scan.retention_time, "mzs": ";".join([str(u) for u in scan.mz_list]), "intensities": ";".join([str(u) for u in scan.intensity_list]), "ionMode": iMode}) - - massSpectraWritten[peak.NPeakCenter] = self.curMassSpectrumID - self.curMassSpectrumID = self.curMassSpectrumID + 1 - - # Update massSpectrumID in chromPeaks - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].with_columns(pl.when(pl.col("id") == peak.id).then(pl.lit(massSpectraWritten[peak.NPeakCenter])).otherwise(pl.col("massSpectrumID")).alias("massSpectrumID")) - - db_con.commit() - db_con.close() - - ## write a new featureML file - def writeResultsToFeatureML(self, forFile): - db_con = PolarsDB(forFile + getDBSuffix(), format=getDBFormat()) - - features = [] - - # Get chromPeaks data directly from Polars DataFrame - df = db_con.tables.get("chromPeaks", pl.DataFrame()) - for row in df.to_dicts(): - chromPeak = ChromPeakPair() - chromPeak.id = row["id"] - chromPeak.mz = row["mz"] - chromPeak.lmz = row["lmz"] - chromPeak.xCount = row["xcount"] - chromPeak.loading = row["Loading"] - chromPeak.NPeakCenterMin = row["NPeakCenterMin"] - chromPeak.ionMode = row["ionMode"] - - b = Bunch( - id=chromPeak.id, - ogroup="-1", - mz=chromPeak.mz, - rt=chromPeak.NPeakCenterMin, - Xn=chromPeak.xCount, - lmz=chromPeak.lmz, - charge=chromPeak.loading, - name=chromPeak.id, - ionMode=chromPeak.ionMode, - ) - features.append(b) - - exportAsFeatureML.writeFeatureListToFeatureML(features, forFile + ".featureML", ppmPM=self.ppm, rtPM=0.25 * 60) - - # store the detected feature pairs in a new mzXML file. Only those MS peaks will be included, which contribute to - # the chromatographic peaks of a valid feature pair - def writeResultsToNewMZXMLIntermediateObject(self, mzxml, newMZXMLData, chromPeaks): - for peak in chromPeaks: - # writeMZXML: 0001/1: 12C 0010/2: 12C-Iso 0100/4: 13C-Iso 1000/8: 13C - - if peak.ionMode == "+": - scanEv = self.positiveScanEvent - elif peak.ionMode == "-": - scanEv = self.negativeScanEvent - - if self.writeMZXML & 1: - scans, times, scanIds = mzxml.getArea( - peak.NPeakCenter - peak.NBorderLeft, - peak.NPeakCenter + peak.NBorderRight, - peak.mz, - self.chromPeakPPM, - scanEv, - ) - - for w in range(len(scans)): - curScan = scans[w] - scanid = scanIds[w] - if scanid not in newMZXMLData: - newMZXMLData[scanid] = Bunch(mzs=[], ints=[], rt=mzxml.getScanByID(scanid).retention_time) - for mz, inte in curScan: - newMZXMLData[scanid].mzs.append(mz) - newMZXMLData[scanid].ints.append(inte) - if self.writeMZXML & 2: - scans, times, scanIds = mzxml.getArea( - peak.NPeakCenter - peak.NBorderLeft, - peak.NPeakCenter + peak.NBorderRight, - peak.mz + self.xOffset / peak.loading, - self.chromPeakPPM, - scanEv, - ) - - for w in range(len(scans)): - curScan = scans[w] - scanid = scanIds[w] - if scanid not in newMZXMLData: - newMZXMLData[scanid] = Bunch(mzs=[], ints=[], rt=mzxml.getScanByID(scanid).retention_time) - for mz, inte in curScan: - newMZXMLData[scanid].mzs.append(mz) - newMZXMLData[scanid].ints.append(inte) - if self.writeMZXML & 4: - scans, times, scanIds = mzxml.getArea( - peak.NPeakCenter - peak.NBorderLeft, - peak.NPeakCenter + peak.NBorderRight, - peak.mz + (peak.xCount - 1) * self.xOffset / peak.loading, - self.chromPeakPPM, - scanEv, - ) - - for w in range(len(scans)): - curScan = scans[w] - scanid = scanIds[w] - if scanid not in newMZXMLData: - newMZXMLData[scanid] = Bunch(mzs=[], ints=[], rt=mzxml.getScanByID(scanid).retention_time) - for mz, inte in curScan: - newMZXMLData[scanid].mzs.append(mz) - newMZXMLData[scanid].ints.append(inte) - if self.writeMZXML & 8: - scans, times, scanIds = mzxml.getArea( - peak.NPeakCenter - peak.NBorderLeft, - peak.NPeakCenter + peak.NBorderRight, - peak.mz + peak.xCount * self.xOffset / peak.loading, - self.chromPeakPPM, - scanEv, - ) - - for w in range(len(scans)): - curScan = scans[w] - scanid = scanIds[w] - if scanid not in newMZXMLData: - newMZXMLData[scanid] = Bunch(mzs=[], ints=[], rt=mzxml.getScanByID(scanid).retention_time) - for mz, inte in curScan: - newMZXMLData[scanid].mzs.append(mz) - newMZXMLData[scanid].ints.append(inte) - - # helper method if more than one tracer substance is used - def writeIntermediateMZXMLDataToNewMZXMLFile(self, mzxml, newMZXMLData): - try: - self.printMessage("Writing MzXML file..", type="info") - if ".mzxml" in self.file.lower(): - toFile = self.file[0 : self.file.lower().rfind(".mzxml")] + ".proc.mzXML" - mzxml.resetMZData(self.file, toFile, newMZXMLData) - elif ".mzml" in self.file.lower() and False: ## mzml export currently not supported - toFile = self.file[0 : self.file.lower().rfind(".mzml")] + "proc.mzML" - self.printMessage("Only mzXML files can be written", type="error") - else: - return RuntimeError("Invalid file. Cannot write new data") - - except Exception as ex: - self.printMessage("Cannot write MzXML file.. (%s)" % ex, type="error") - - # stores the TICs of the LC-HRMS data in the database - def writeTICsToDB(self, mzxml, scanEvents): - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - ## save TICs - ## save mean, sd scan time - i = 1 - for pol, scanEvent in scanEvents.items(): - if scanEvent != "None": - TIC, times, scanIds = mzxml.getTIC(filterLine=scanEvent) - - # Insert into tics table - db_con.insert_row("tics", {"id": i, "polarity": pol, "scanevent": scanEvent, "times": ";".join([str(t) for t in times]), "intensities": ";".join(["%.0f" % t for t in TIC])}) - i = i + 1 - - scanTimes = [times[i + 1] - times[i] for i in range(len(times) - 1)] - - # Insert stats - db_con.insert_row("stats", {"key": "MeanScanTime_%s" % pol, "value": str(mean(scanTimes))}) - db_con.insert_row("stats", {"key": "SDScanTime_%s" % pol, "value": str(sd(scanTimes))}) - db_con.insert_row("stats", {"key": "NumberOfScans_%s" % pol, "value": str(len(times))}) - db_con.insert_row("stats", {"key": "TotalNumberOfSignals_%s" % pol, "value": str(mzxml.getSignalCount(filterLine=scanEvent))}) - - minInt, maxInt, avgInt = mzxml.getMinMaxAvgSignalIntensities(filterLine=scanEvent) - db_con.insert_row("stats", {"key": "MinSignalInt_%s" % pol, "value": str(minInt)}) - db_con.insert_row("stats", {"key": "MaxSignalInt_%s" % pol, "value": str(maxInt)}) - db_con.insert_row("stats", {"key": "AvgSignalInt_%s" % pol, "value": str(avgInt)}) - - db_con.commit() - db_con.close() - - # method, which is called by the multiprocessing module to actually process the LC-HRMS data - def findIsoPairs(self): - try: - start = time.time() - - # region Initialize data processing pipeline - ###################################################################################### - - self.postMessageToProgressWrapper("start") - self.postMessageToProgressWrapper("max", 100.0) - self.postMessageToProgressWrapper("value", 0.0) - self.postMessageToProgressWrapper("text", "Initialising") - - self.BL = Baseline.Baseline() - - self.curPeakId = 1 - self.curEICId = 1 - self.curMZId = 1 - self.curMZBinId = 1 - self.curFeatureGroupID = 1 - self.curMassSpectrumID = 1 - # endregion - - # region Create results database - ###################################################################################### - self.postMessageToProgressWrapper("text", "Creating results DB") - - if os.path.exists(self.file + getDBSuffix()) and os.path.isfile(self.file + getDBSuffix()): - os.remove(self.file + getDBSuffix()) - db_con = PolarsDB(self.file + getDBSuffix(), format=getDBFormat()) - - self.writeConfigurationToDB(db_con) - - db_con.close() - # endregion - - # region Parse mzXML file - ###################################################################################### - - self.postMessageToProgressWrapper("text", "Parsing chromatogram file") - - mzxml = self.parseMzXMLFile() - newMZXMLData = {} - - self.writeTICsToDB(mzxml, {"+": self.positiveScanEvent, "-": self.negativeScanEvent}) - - # endregion - - # Start calculation - ###################################################################################### - - self.postMessageToProgressWrapper("text", "Starting data processing") - tracerProgressWidth = 100.0 / 1 - - tracerNum = 1 - - # region Process configured tracer - ###################################################################################### - - tracer = self.configuredTracer - - curTracerProgress = tracerNum / 1 - - tracerID = 0 - if self.metabolisationExperiment: - if tracer is None: - self.printMessage("Metabolisation experiment requires a configured tracer, but none was available in the identify method.", type="error") - raise Exception("Metabolisation experiment requires a configured tracer, but none was available in the identify method.") - - tracerID = tracer.id - self.printMessage("Tracer: %s" % tracer.name, type="info") - - ################################################################################################## - # Attention: delta mz for one labelling atom is always saved in the member variable self.xOffset # - ################################################################################################## - - self.xOffset = getIsotopeMass(tracer.isotopeB)[0] - getIsotopeMass(tracer.isotopeA)[0] - - else: - # Full metabolome labelling experiment - pass - # endregion - - # region 1. Find 12C 13C partners in the mz dimension (0-25%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Extracting signal pairs" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0 * tracerProgressWidth + 0.25 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper("text", "%s: Extracting signal pairs (%s)" % (tracer.name, text)) - - mzs, negFound, posFound = self.findSignalPairs(curTracerProgress, mzxml, tracer, reportFunction) - self.writeSignalPairsToDB(mzs, mzxml, tracerID) - - self.printMessage( - "%s: Extracting signal pairs done. pos: %d neg: %d mzs (including mismatches)" % (tracer.name, posFound, negFound), - type="info", - ) - # endregion - - # region 2. Cluster found mz values according to mz value and number of x atoms (25-35%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.25 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Clustering found signal pairs" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.25 * tracerProgressWidth + 0.1 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper( - "text", - "%s: Clustering found signal pairs (%s)" % (tracer.name, text), - ) - - mzbins = self.clusterFeaturePairs(mzs, reportFunction) - self.writeFeaturePairClustersToDB(mzbins) - mzbins = self.removeImpossibleFeaturePairClusters(mzbins) - - self.printMessage( - "%s: Clustering found signal pairs done. pos: %d neg: %d mz bins (including mismatches)" % (tracer.name, len(mzbins["+"]), len(mzbins["-"])), - type="info", - ) - # endregion - - # region 3. Extract chromatographic peaks (35-65%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.35 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Separating feature pairs" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.35 * tracerProgressWidth + 0.3 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper("text", "%s: Separating feature pairs (%s)" % (tracer.name, text)) - - chromPeaks = self.findChromatographicPeaksAndWriteToDB(mzbins, mzxml, tracerID, reportFunction) - - self.printMessage( - "%s: Separating feature pairs done. pos: %d neg: %d chromatographic peaks (including mismatches)" - % ( - tracer.name, - len([c for c in chromPeaks if c.ionMode == "+"]), - len([c for c in chromPeaks if c.ionMode == "-"]), - ), - type="info", - ) - # endregion - - # region 4. Remove isotopolog feature pairs and other false positive findings (65-70%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.65 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Removing false positive feature pairs" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.65 * tracerProgressWidth + 0.05 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper( - "text", - "%s: Removing false positive feature pairs (%s)" % (tracer.name, text), - ) - - self.removeFalsePositiveFeaturePairsAndUpdateDB(chromPeaks, reportFunction) - - self.printMessage( - "%s: Removing false positive feature pairs done. %d chromatographic peaks" % (tracer.name, len(chromPeaks)), - type="info", - ) - # endregion - - # region 5. Search for hetero atoms (70-75%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.7 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Searching for hetero atoms" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.7 * tracerProgressWidth + 0.05 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper("text", "%s: Annotating feature pairs (%s)" % (tracer.name, text)) - - self.annotateFeaturePairs(chromPeaks, mzxml, tracer, reportFunction) - # endregion - - # region 6. Group feature pairs untargeted using chromatographic peak shape into feature groups (75-80%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.75 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Grouping feature pairs" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.75 * tracerProgressWidth + 0.05 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper("text", "%s: Grouping feature pairs (%s)" % (tracer.name, text)) - - self.groupFeaturePairsUntargetedAndWriteToDB(chromPeaks, mzxml, tracer, tracerID, reportFunction) - # endregion - - # region 7. Extract mass spectra for feature pairs and feature groups (80-95%) - ###################################################################################### - - self.postMessageToProgressWrapper("value", curTracerProgress + 0.8 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Extracting mass spectra to DB" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper( - "value", - curTracerProgress + 0.8 * tracerProgressWidth + 0.15 * curVal * tracerProgressWidth, - ) - self.postMessageToProgressWrapper( - "text", - "%s: Extracting mass spectra to DB (%s)" % (tracer.name, text), - ) - - self.writeMassSpectraToDB(chromPeaks, mzxml, reportFunction) - - # Log time used for processing of individual files - elapsed = (time.time() - start) / 60.0 - hours = "" - if elapsed >= 60.0: - if elapsed < 120.0: - hours = "1 hour " - else: - hours = "%d hours " % (elapsed // 60) - mins = "%.2f min(s)" % (elapsed % 60.0) - - self.printMessage( - "%s: Calculations finished (%s%s).." % (tracer.name, hours, mins), - type="info", - ) - # endregion - - # region 7b. Calculate isotopolog ratios for each feature pair - ###################################################################################### - - self.postMessageToProgressWrapper("text", "%s: Calculating isotopolog ratios" % tracer.name) - - def reportFunction(curVal, text): - self.postMessageToProgressWrapper("text", "%s: Calculating isotopolog ratios (%s)" % (tracer.name, text)) - - self.calculateIsotopologRatiosForFeaturePairs(chromPeaks, mzxml, reportFunction) - - self.printMessage( - "%s: Isotopolog ratio calculation done." % tracer.name, - type="info", - ) - # endregion - - # region 8. Write results to files (95-100%, without progress indicator) - ###################################################################################### - - # W.1 Save results to new MzXML file (intermediate step) (95-100%) - - if self.writeMZXML: - self.postMessageToProgressWrapper("value", curTracerProgress + 0.95 * tracerProgressWidth) - self.postMessageToProgressWrapper("text", "%s: Writing results to mzXML.." % tracer.name) - - self.writeResultsToNewMZXMLIntermediateObject(mzxml, newMZXMLData, chromPeaks) - # endregion - - self.postMessageToProgressWrapper("text", "Writing results to mzXML..") - - # region W.2 Write results to TSV File - ########################################################################################## - if self.writeFeatureML: - self.postMessageToProgressWrapper("text", "Writing results to featureML..") - - self.writeResultsToFeatureML(self.file) - # endregion - - # region W.4 Save all results to new MzXML file - ########################################################################################## - if self.writeMZXML: - self.postMessageToProgressWrapper("text", "Writing results to new mzXML file") - - self.writeIntermediateMZXMLDataToNewMZXMLFile(mzxml, newMZXMLData) - # endregion - - mzxml.freeMe() - self.printMessage("%s done.." % self.file, type="info") - self.postMessageToProgressWrapper("end") - - except Exception as ex: - self.printMessage("Error in %s: %s" % (self.file, str(ex)), type="error") - self.postMessageToProgressWrapper("failed") - traceback.print_exc() diff --git a/uv.lock b/uv.lock index 7234aae..e40db96 100644 --- a/uv.lock +++ b/uv.lock @@ -40,6 +40,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.3" @@ -175,6 +184,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/25/dd83b280a4a405f7e1558951492714935e0c1601724d35a0db970afd0ad0/dbus_fast-4.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:069df5d46390299d7fccfc0e704af504a8b75794c2c3bc0246b6db0db1f245b0", size = 849052, upload-time = "2026-04-02T04:50:16.563Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "desktop-notifier" version = "6.2.0" @@ -250,6 +271,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/a4/247d3e54eb5ed59e94e09866cfc4f9567e274fbf310ba390711851f63b3b/fonttools-4.60.0-py3-none-any.whl", hash = "sha256:496d26e4d14dcccdd6ada2e937e4d174d3138e3d73f5c9b6ec6eb2fd1dab4f66", size = 1142186, upload-time = "2025-09-17T11:33:59.287Z" }, ] +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -301,30 +331,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, ] +[[package]] +name = "llvmlite" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, +] + [[package]] name = "lxml" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214, upload-time = "2025-08-22T10:37:53.525Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139, upload-time = "2025-08-22T10:33:34.09Z" }, - { url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954, upload-time = "2025-08-22T10:33:36.294Z" }, - { url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052, upload-time = "2025-08-22T10:33:38.19Z" }, - { url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885, upload-time = "2025-08-22T10:33:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542, upload-time = "2025-08-22T10:33:41.896Z" }, - { url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303, upload-time = "2025-08-22T10:33:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055, upload-time = "2025-08-22T10:33:45.784Z" }, - { url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719, upload-time = "2025-08-22T10:33:48.089Z" }, - { url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310, upload-time = "2025-08-22T10:33:49.852Z" }, - { url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024, upload-time = "2025-08-22T10:33:52.22Z" }, - { url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335, upload-time = "2025-08-22T10:33:54.041Z" }, - { url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864, upload-time = "2025-08-22T10:33:56.382Z" }, - { url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173, upload-time = "2025-08-22T10:33:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896, upload-time = "2025-08-22T10:34:00.586Z" }, - { url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417, upload-time = "2025-08-22T10:34:02.719Z" }, - { url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051, upload-time = "2025-08-22T10:34:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325, upload-time = "2025-08-22T10:34:06.24Z" }, - { url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443, upload-time = "2025-08-22T10:34:07.974Z" }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, ] [[package]] @@ -339,9 +382,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" }, ] +[[package]] +name = "matchms" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "lxml" }, + { name = "matplotlib" }, + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pickydict" }, + { name = "pubchempy" }, + { name = "pynndescent" }, + { name = "pyteomics" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "requests" }, + { name = "scipy" }, + { name = "sparsestack" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/0b/29e9b7c2eb19391af23be895ce85dad5922edc68840fca1143e018b8fe3a/matchms-0.33.0.tar.gz", hash = "sha256:a4cf7ffab8190ac909b598fa83565e2509f584d53bde3fab1aba993e9d194b0a", size = 394784, upload-time = "2026-05-12T13:19:41.844Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/26/f975122c7cc4ba9960f67be3e4fea5d23e04a691df95ea95991087ddd0bf/matchms-0.33.0-py3-none-any.whl", hash = "sha256:39774cd39e8e643cceed99c03003ed57deaafe70156f70130ceec2f40c97273f", size = 213756, upload-time = "2026-05-12T13:19:43.653Z" }, +] + [[package]] name = "matplotlib" -version = "3.10.6" +version = "3.10.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -354,22 +425,22 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/db/18380e788bb837e724358287b08e223b32bc8dccb3b0c12fa8ca20bc7f3b/matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a", size = 8273231, upload-time = "2025-08-30T00:13:13.881Z" }, - { url = "https://files.pythonhosted.org/packages/d3/0f/38dd49445b297e0d4f12a322c30779df0d43cb5873c7847df8a82e82ec67/matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf", size = 8128730, upload-time = "2025-08-30T00:13:15.556Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a", size = 8698539, upload-time = "2025-08-30T00:13:17.297Z" }, - { url = "https://files.pythonhosted.org/packages/71/34/44c7b1f075e1ea398f88aeabcc2907c01b9cc99e2afd560c1d49845a1227/matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110", size = 9529702, upload-time = "2025-08-30T00:13:19.248Z" }, - { url = "https://files.pythonhosted.org/packages/b5/7f/e5c2dc9950c7facaf8b461858d1b92c09dd0cf174fe14e21953b3dda06f7/matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2", size = 9593742, upload-time = "2025-08-30T00:13:21.181Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1d/70c28528794f6410ee2856cd729fa1f1756498b8d3126443b0a94e1a8695/matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18", size = 8122753, upload-time = "2025-08-30T00:13:23.44Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/0e1670501fc7d02d981564caf7c4df42974464625935424ca9654040077c/matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6", size = 7992973, upload-time = "2025-08-30T00:13:26.632Z" }, - { url = "https://files.pythonhosted.org/packages/b1/4e/60780e631d73b6b02bd7239f89c451a72970e5e7ec34f621eda55cd9a445/matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f", size = 8316869, upload-time = "2025-08-30T00:13:28.262Z" }, - { url = "https://files.pythonhosted.org/packages/f8/15/baa662374a579413210fc2115d40c503b7360a08e9cc254aa0d97d34b0c1/matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27", size = 8178240, upload-time = "2025-08-30T00:13:30.007Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3f/3c38e78d2aafdb8829fcd0857d25aaf9e7dd2dfcf7ec742765b585774931/matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833", size = 8711719, upload-time = "2025-08-30T00:13:31.72Z" }, - { url = "https://files.pythonhosted.org/packages/96/4b/2ec2bbf8cefaa53207cc56118d1fa8a0f9b80642713ea9390235d331ede4/matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa", size = 9541422, upload-time = "2025-08-30T00:13:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/83/7d/40255e89b3ef11c7871020563b2dd85f6cb1b4eff17c0f62b6eb14c8fa80/matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706", size = 9594068, upload-time = "2025-08-30T00:13:35.833Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a9/0213748d69dc842537a113493e1c27daf9f96bd7cc316f933dc8ec4de985/matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e", size = 8200100, upload-time = "2025-08-30T00:13:37.668Z" }, - { url = "https://files.pythonhosted.org/packages/be/15/79f9988066ce40b8a6f1759a934ea0cde8dc4adc2262255ee1bc98de6ad0/matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5", size = 8042142, upload-time = "2025-08-30T00:13:39.426Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, ] [[package]] @@ -410,34 +481,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "numba" +version = "0.61.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" }, +] + [[package]] name = "numpy" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, - { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, - { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, - { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, - { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, ] [[package]] @@ -506,6 +601,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, ] +[[package]] +name = "pickydict" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/20/ec9aa324363cc10a9f2cf37bda9a469f1f725dcce4e25b8c37adbdcff303/pickydict-0.5.0.tar.gz", hash = "sha256:fc66944dc5782c1078ffe41cf4dd8fdf6702f9d63c5373748403c12ea2da8cc6", size = 5514, upload-time = "2024-09-25T16:07:46.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ad/40ba18d3a88ccce689687a5b6b246f7c651823c48d2e3051d9cd53afafe5/pickydict-0.5.0-py3-none-any.whl", hash = "sha256:a6614d2aacbe07d3d4b0b6e7c9e9834eae7b8c0127bad4b1127b6fdcebd5cd65", size = 6228, upload-time = "2024-09-25T16:07:45.617Z" }, +] + [[package]] name = "pillow" version = "11.3.0" @@ -601,6 +705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" }, ] +[[package]] +name = "pubchempy" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/a1/1b63d717a315b5fdc281e312bd02e9069e22ed8aa63a56d79f8dae95a1f2/pubchempy-1.0.5.tar.gz", hash = "sha256:08f0b2a82a5caa5d61e14935d655da554602d7b5686fe661ab584c882ffff623", size = 26814, upload-time = "2025-09-08T20:53:01.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/e3/2c887645f21b94d992a16775bcf81cdf6dcc36cf1606782cb2a3c86e9a35/pubchempy-1.0.5-py3-none-any.whl", hash = "sha256:e936cfed31fa194042ad463be3c803dde5b12ef2f795caf336e3114127c34fa0", size = 21355, upload-time = "2025-09-08T20:53:00.831Z" }, +] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -678,6 +791,7 @@ dependencies = [ { name = "desktop-notifier" }, { name = "fastexcel" }, { name = "lxml" }, + { name = "matchms" }, { name = "matplotlib" }, { name = "numpy" }, { name = "openpyxl" }, @@ -723,6 +837,7 @@ requires-dist = [ { name = "fastexcel", specifier = ">=0.19.0" }, { name = "flake8", marker = "extra == 'dev'", specifier = ">=3.8" }, { name = "lxml", specifier = ">=4.6.0" }, + { name = "matchms", specifier = ">=0.33.0" }, { name = "matplotlib", specifier = ">=3.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=0.900" }, { name = "numpy", specifier = ">=1.20.0" }, @@ -768,6 +883,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/6b/a3547bc34db0e8b37f55524df915271a67b15164f838d4d1c3b2374697eb/pymzml-2.5.11-py3-none-any.whl", hash = "sha256:f0f507f94977fefd2b319a613c5b55679db858374aadae31769603994d0ad64f", size = 17466151, upload-time = "2025-01-31T14:03:05.612Z" }, ] +[[package]] +name = "pynndescent" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "llvmlite" }, + { name = "numba" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/fb/7f58c397fb31666756457ee2ac4c0289ef2daad57f4ae4be8dec12f80b03/pynndescent-0.6.0.tar.gz", hash = "sha256:7ffde0fb5b400741e055a9f7d377e3702e02250616834231f6c209e39aac24f5", size = 2992987, upload-time = "2026-01-08T21:29:58.943Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e6/94145d714402fd5ade00b5661f2d0ab981219e07f7db9bfa16786cdb9c04/pynndescent-0.6.0-py3-none-any.whl", hash = "sha256:dc8c74844e4c7f5cbd1e0cd6909da86fdc789e6ff4997336e344779c3d5538ef", size = 73511, upload-time = "2026-01-08T21:29:57.306Z" }, +] + [[package]] name = "pyparsing" version = "3.2.4" @@ -834,6 +965,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/70/db78afc8b60b2e53f99145bde2f644cca43924a4dd869ffe664e0792730a/pyside6_essentials-6.9.2-cp39-abi3-win_arm64.whl", hash = "sha256:ecd7b5cd9e271f397fb89a6357f4ec301d8163e50869c6c557f9ccc6bed42789", size = 49561720, upload-time = "2025-08-26T07:49:43.708Z" }, ] +[[package]] +name = "pyteomics" +version = "4.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cd/b7eb951a99ad3ab425a2dbc6e38af076116d13ebcbe144019a2bd37dc093/pyteomics-4.7.5.tar.gz", hash = "sha256:382aeaa8b921bdd2a7e5b4aa9fe46c6184bb43701205a845b4b861ee3e88f46a", size = 236493, upload-time = "2024-10-18T13:26:29.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/59/2ea7637f20a0e2d082fc0cae021d20934aa49ac8154db817a1a3fa9e27b5/pyteomics-4.7.5-py2.py3-none-any.whl", hash = "sha256:9b8008ad8d8bbbc6856c4e804bc88e018df44809cd9a86900862b311e760862d", size = 238983, upload-time = "2024-10-18T13:26:25.923Z" }, + { url = "https://files.pythonhosted.org/packages/fb/62/b5d706255739553398d3a308d92e2476d5a363ad3ca0598c51bc75cc5054/pyteomics-4.7.5-py3-none-any.whl", hash = "sha256:5155e1d2581845926e49b0abd0be8cfd6ea45ffd3511958b805347037c5934c8", size = 238978, upload-time = "2024-10-18T13:26:27.764Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -929,6 +1070,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "rdkit" +version = "2026.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c1/b3e8469291a9725eda29e911987f39be8a625b78f78d64bdc731ddda852f/rdkit-2026.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c30bf6846b2f55a24b0e2beba7f0480dd948a73bfc13ad95cdb29f839586a497", size = 29984328, upload-time = "2026-05-16T11:47:10.123Z" }, + { url = "https://files.pythonhosted.org/packages/f2/46/47f8ab91a05a6b060852a8484b555256a8f32b740c67560b00560ef56eb5/rdkit-2026.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fb14f634f630cac27e6484bf01a789a2ae8e8dbea96c87e365ff956b31c384a2", size = 35575509, upload-time = "2026-05-16T11:47:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ed/568c508712bb4f1b94b9b85f3a514aedcd37da73fb08d6ef22a3b0981698/rdkit-2026.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9908eff69e89da927006b9513adaf7985b46bf0b213cf204a2529d367e932dda", size = 37089613, upload-time = "2026-05-16T11:47:20.002Z" }, + { url = "https://files.pythonhosted.org/packages/e7/39/8e095f9406d7416b9c1284817c62049dccc801008ee98a8aaa7a68c609bf/rdkit-2026.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:b4a3e15fd1e9986284288362ef415478d5a895c9409c72a3fa9320e728b76ca3", size = 24612214, upload-time = "2026-05-16T11:47:23.628Z" }, +] + [[package]] name = "regex" version = "2025.11.3" @@ -978,6 +1152,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/c8/aaf4e08679e7b1dc896ad30de0d0527f0fd55582c2e6deee4f2cc899bf9f/reportlab-4.4.3-py3-none-any.whl", hash = "sha256:df905dc5ec5ddaae91fc9cb3371af863311271d555236410954961c5ee6ee1b5", size = 1953896, upload-time = "2025-07-23T11:18:20.572Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rubicon-objc" version = "0.5.4" @@ -1013,33 +1202,31 @@ wheels = [ [[package]] name = "scipy" -version = "1.16.2" +version = "1.15.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/3b/546a6f0bfe791bbb7f8d591613454d15097e53f906308ec6f7c1ce588e8e/scipy-1.16.2.tar.gz", hash = "sha256:af029b153d243a80afb6eabe40b0a07f8e35c9adc269c019f364ad747f826a6b", size = 30580599, upload-time = "2025-09-11T17:48:08.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/27/c5b52f1ee81727a9fc457f5ac1e9bf3d6eab311805ea615c83c27ba06400/scipy-1.16.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:84f7bf944b43e20b8a894f5fe593976926744f6c185bacfcbdfbb62736b5cc70", size = 36604856, upload-time = "2025-09-11T17:41:47.695Z" }, - { url = "https://files.pythonhosted.org/packages/32/a9/15c20d08e950b540184caa8ced675ba1128accb0e09c653780ba023a4110/scipy-1.16.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5c39026d12edc826a1ef2ad35ad1e6d7f087f934bb868fc43fa3049c8b8508f9", size = 28864626, upload-time = "2025-09-11T17:41:52.642Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fc/ea36098df653cca26062a627c1a94b0de659e97127c8491e18713ca0e3b9/scipy-1.16.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e52729ffd45b68777c5319560014d6fd251294200625d9d70fd8626516fc49f5", size = 20855689, upload-time = "2025-09-11T17:41:57.886Z" }, - { url = "https://files.pythonhosted.org/packages/dc/6f/d0b53be55727f3e6d7c72687ec18ea6d0047cf95f1f77488b99a2bafaee1/scipy-1.16.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:024dd4a118cccec09ca3209b7e8e614931a6ffb804b2a601839499cb88bdf925", size = 23512151, upload-time = "2025-09-11T17:42:02.303Z" }, - { url = "https://files.pythonhosted.org/packages/11/85/bf7dab56e5c4b1d3d8eef92ca8ede788418ad38a7dc3ff50262f00808760/scipy-1.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a5dc7ee9c33019973a470556081b0fd3c9f4c44019191039f9769183141a4d9", size = 33329824, upload-time = "2025-09-11T17:42:07.549Z" }, - { url = "https://files.pythonhosted.org/packages/da/6a/1a927b14ddc7714111ea51f4e568203b2bb6ed59bdd036d62127c1a360c8/scipy-1.16.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c2275ff105e508942f99d4e3bc56b6ef5e4b3c0af970386ca56b777608ce95b7", size = 35681881, upload-time = "2025-09-11T17:42:13.255Z" }, - { url = "https://files.pythonhosted.org/packages/c1/5f/331148ea5780b4fcc7007a4a6a6ee0a0c1507a796365cc642d4d226e1c3a/scipy-1.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:af80196eaa84f033e48444d2e0786ec47d328ba00c71e4299b602235ffef9acb", size = 36006219, upload-time = "2025-09-11T17:42:18.765Z" }, - { url = "https://files.pythonhosted.org/packages/46/3a/e991aa9d2aec723b4a8dcfbfc8365edec5d5e5f9f133888067f1cbb7dfc1/scipy-1.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9fb1eb735fe3d6ed1f89918224e3385fbf6f9e23757cacc35f9c78d3b712dd6e", size = 38682147, upload-time = "2025-09-11T17:42:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/a1/57/0f38e396ad19e41b4c5db66130167eef8ee620a49bc7d0512e3bb67e0cab/scipy-1.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:fda714cf45ba43c9d3bae8f2585c777f64e3f89a2e073b668b32ede412d8f52c", size = 38520766, upload-time = "2025-09-11T17:43:25.342Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/85d3e867b6822d331e26c862a91375bb7746a0b458db5effa093d34cdb89/scipy-1.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:2f5350da923ccfd0b00e07c3e5cfb316c1c0d6c1d864c07a72d092e9f20db104", size = 25451169, upload-time = "2025-09-11T17:43:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/09/d9/60679189bcebda55992d1a45498de6d080dcaf21ce0c8f24f888117e0c2d/scipy-1.16.2-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:53d8d2ee29b925344c13bda64ab51785f016b1b9617849dac10897f0701b20c1", size = 37012682, upload-time = "2025-09-11T17:42:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/83/be/a99d13ee4d3b7887a96f8c71361b9659ba4ef34da0338f14891e102a127f/scipy-1.16.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:9e05e33657efb4c6a9d23bd8300101536abd99c85cca82da0bffff8d8764d08a", size = 29389926, upload-time = "2025-09-11T17:42:35.845Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0a/130164a4881cec6ca8c00faf3b57926f28ed429cd6001a673f83c7c2a579/scipy-1.16.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:7fe65b36036357003b3ef9d37547abeefaa353b237e989c21027b8ed62b12d4f", size = 21381152, upload-time = "2025-09-11T17:42:40.07Z" }, - { url = "https://files.pythonhosted.org/packages/47/a6/503ffb0310ae77fba874e10cddfc4a1280bdcca1d13c3751b8c3c2996cf8/scipy-1.16.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6406d2ac6d40b861cccf57f49592f9779071655e9f75cd4f977fa0bdd09cb2e4", size = 23914410, upload-time = "2025-09-11T17:42:44.313Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c7/1147774bcea50d00c02600aadaa919facbd8537997a62496270133536ed6/scipy-1.16.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff4dc42bd321991fbf611c23fc35912d690f731c9914bf3af8f417e64aca0f21", size = 33481880, upload-time = "2025-09-11T17:42:49.325Z" }, - { url = "https://files.pythonhosted.org/packages/6a/74/99d5415e4c3e46b2586f30cdbecb95e101c7192628a484a40dd0d163811a/scipy-1.16.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:654324826654d4d9133e10675325708fb954bc84dae6e9ad0a52e75c6b1a01d7", size = 35791425, upload-time = "2025-09-11T17:42:54.711Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ee/a6559de7c1cc710e938c0355d9d4fbcd732dac4d0d131959d1f3b63eb29c/scipy-1.16.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63870a84cd15c44e65220eaed2dac0e8f8b26bbb991456a033c1d9abfe8a94f8", size = 36178622, upload-time = "2025-09-11T17:43:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/4e/7b/f127a5795d5ba8ece4e0dce7d4a9fb7cb9e4f4757137757d7a69ab7d4f1a/scipy-1.16.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:fa01f0f6a3050fa6a9771a95d5faccc8e2f5a92b4a2e5440a0fa7264a2398472", size = 38783985, upload-time = "2025-09-11T17:43:06.661Z" }, - { url = "https://files.pythonhosted.org/packages/3e/9f/bc81c1d1e033951eb5912cd3750cc005943afa3e65a725d2443a3b3c4347/scipy-1.16.2-cp313-cp313t-win_amd64.whl", hash = "sha256:116296e89fba96f76353a8579820c2512f6e55835d3fad7780fece04367de351", size = 38631367, upload-time = "2025-09-11T17:43:14.44Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5e/2cc7555fd81d01814271412a1d59a289d25f8b63208a0a16c21069d55d3e/scipy-1.16.2-cp313-cp313t-win_arm64.whl", hash = "sha256:98e22834650be81d42982360382b43b17f7ba95e0e6993e2a4f5b9ad9283a94d", size = 25787992, upload-time = "2025-09-11T17:43:19.745Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, ] [[package]] @@ -1072,6 +1259,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sparsestack" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/9a/d2493b2760052cac9661304e2010616a377eba9101f5f1b9c3441af41dfc/sparsestack-0.7.0.tar.gz", hash = "sha256:920d81f130f9390bcf491ddb60e5924d1223eaa63d818b8476b4eb57e461ed5b", size = 9348, upload-time = "2025-05-26T15:31:21.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/06/1b8dc3021ec7139db7cfd99ecd3853d953f44cdab239ad79006111569335/sparsestack-0.7.0-py3-none-any.whl", hash = "sha256:6e63a9d549397bf8c0ae7df407fe91f40d0076191bdb5e4ff043d26e2fbfc0e3", size = 9776, upload-time = "2025-05-26T15:31:19.836Z" }, +] + [[package]] name = "termcolor" version = "3.1.0" @@ -1090,6 +1291,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1108,6 +1321,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + [[package]] name = "winrt-runtime" version = "3.2.1" @@ -1192,6 +1414,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/10/89012eeb14b932eb3f0ac541d8788d794e09f8fe1934fdae1760d0cbd252/winrt_windows_ui_notifications-3.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:aec78f6a632e2ae57a55b7aee60f8df848ccffd1c4d14c3c9fbfaa6bb564e1a2", size = 155184, upload-time = "2025-06-06T14:11:53.863Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "xlrd" version = "2.0.2" From 3c9b56331ab52b6a3adde1491d058f95f6a9aea7 Mon Sep 17 00:00:00 2001 From: chrboku Date: Tue, 19 May 2026 15:24:36 +0200 Subject: [PATCH 27/27] performance improvements for grouping --- src/MExtract.py | 185 +++++++++++++++++++++++++++--- src/findIsoPairs.py | 91 +++++++++++---- src/findIsoPairs_matchPartners.py | 6 +- 3 files changed, 242 insertions(+), 40 deletions(-) diff --git a/src/MExtract.py b/src/MExtract.py index 6fda8a6..036323b 100644 --- a/src/MExtract.py +++ b/src/MExtract.py @@ -3968,7 +3968,7 @@ def runProcess(self, dontSave=False, askStarting=True): completed = res._index if completed == len(files): loop = False - + pwMain("value")(completed) mess = {} @@ -4020,12 +4020,7 @@ def runProcess(self, dontSave=False, askStarting=True): pw.getCallingFunction(assignedThreads[mes.pid])("text")("") pw.getCallingFunction(assignedThreads[mes.pid])("value")(0) - logging.info("\n" + - "##############################################################\n" + - "\n".join(messages_to_print[mes.pid])+ - "\n" + - "##############################################################\n"+ - "") + logging.info("\n" + "##############################################################\n" + "\n".join(messages_to_print[mes.pid]) + "\n" + "##############################################################\n" + "") pw.getCallingFunction()("statuscolor")( pIds[mes.pid], @@ -6315,6 +6310,11 @@ def _resolve_col_name(self, df, *candidate_names): def selectedResChanged(self): annotationPPM = self.ui.doubleSpinBox_isotopologAnnotationPPM.value() + # Always restore "Result name" row; individual branches may hide it again + self.ui.label_22.setVisible(True) + self.ui.chromPeakName.setVisible(True) + self.ui.setChromPeakName.setVisible(True) + for i in range(self.ui.res_ExtractedData.topLevelItemCount()): self.deColorQTreeWidgetItem(self.ui.res_ExtractedData.topLevelItem(i)) @@ -6496,6 +6496,10 @@ def selectedResChanged(self): # elif item.myType == "Features" or item.myType == "feature": self.ui.chromPeakName.setText("") + if item.myType == "Features": + self.ui.label_22.setVisible(False) + self.ui.chromPeakName.setVisible(False) + self.ui.setChromPeakName.setVisible(False) self.ui.res_ExtractedData.setHeaderLabels( [ @@ -6517,17 +6521,34 @@ def selectedResChanged(self): _fm_rts = [] _fm_mzs = [] _fm_areas = [] + _fm_point_data = [] plotTypes.add("Features") plotTypes.add("FeatureMap") for _fmi in range(item.childCount()): _fmc = item.child(_fmi) assert _fmc.myType == "feature" - _fm_rts.append(_fmc.myData.NPeakCenterMin / 60.0) - _fm_mzs.append(_fmc.myData.mz) + _rt = _fmc.myData.NPeakCenterMin / 60.0 + _mz = _fmc.myData.mz try: - _fm_areas.append(max(1.0, float(_fmc.text(7).split(" / ")[0]))) + _area = max(1.0, float(_fmc.text(7).split(" / ")[0])) except Exception: - _fm_areas.append(1.0) + _area = 1.0 + _fm_rts.append(_rt) + _fm_mzs.append(_mz) + _fm_areas.append(_area) + _fm_point_data.append( + { + "rt": _rt, + "mz": _mz, + "native_area": _area, + "id": _fmc.myData.id, + "ogroup": "N/A", + "polarity": _fmc.myData.ionMode, + "charge": _fmc.myData.loading, + "xcount": _fmc.myData.xCount, + "tree_item": _fmc, + } + ) if _fm_areas: import math as _math @@ -6539,6 +6560,7 @@ def selectedResChanged(self): else: _fm_sizes = [] _ax_fm = self.ui.pl1.twinxs[0] + _ax_fm._fm_point_data = _fm_point_data _ax_fm.scatter( _fm_rts, _fm_mzs, @@ -6914,6 +6936,10 @@ def selectedResChanged(self): # elif item.myType == "featureGroup" or item.myType == "Feature Groups": + if item.myType == "Feature Groups": + self.ui.label_22.setVisible(False) + self.ui.chromPeakName.setVisible(False) + self.ui.setChromPeakName.setVisible(False) self.ui.res_ExtractedData.setHeaderLabels( [ "Feature group / MZ (/Ionmode Z)", @@ -6947,20 +6973,39 @@ def selectedResChanged(self): _all_fm_mzs = [] _all_fm_sizes = [] _all_fm_colors = [] + _all_fm_point_data = [] + _fm_group_lines = [] for _gi in range(item.childCount()): _gc = item.child(_gi) _g_rts = [] _g_mzs = [] _g_areas = [] + _g_group_name = _gc.myData.featureName if hasattr(_gc, "myData") and hasattr(_gc.myData, "featureName") else str(_gc.text(0)) for _gfi in range(_gc.childCount()): _gff = _gc.child(_gfi) assert _gff.myType == "feature" - _g_rts.append(_gff.myData.NPeakCenterMin / 60.0) - _g_mzs.append(_gff.myData.mz) + _rt = _gff.myData.NPeakCenterMin / 60.0 + _mz = _gff.myData.mz try: - _g_areas.append(max(1.0, float(_gff.text(7).split(" / ")[0]))) + _area = max(1.0, float(_gff.text(7).split(" / ")[0])) except Exception: - _g_areas.append(1.0) + _area = 1.0 + _g_rts.append(_rt) + _g_mzs.append(_mz) + _g_areas.append(_area) + _all_fm_point_data.append( + { + "rt": _rt, + "mz": _mz, + "native_area": _area, + "id": _gff.myData.id, + "ogroup": _g_group_name, + "polarity": _gff.myData.ionMode, + "charge": _gff.myData.loading, + "xcount": _gff.myData.xCount, + "tree_item": _gff, + } + ) if _g_areas: _g_log = [_math.log10(a) for a in _g_areas] _g_min = min(_g_log) @@ -6974,7 +7019,9 @@ def selectedResChanged(self): _all_fm_mzs.extend(_g_mzs) _all_fm_sizes.extend(_g_sizes) _all_fm_colors.extend([_col] * len(_g_rts)) + _fm_group_lines.append((_g_rts, _g_mzs, _col)) _ax_fg = self.ui.pl1.twinxs[0] + _ax_fg._fm_point_data = _all_fm_point_data _ax_fg.scatter( _all_fm_rts, _all_fm_mzs, @@ -6982,6 +7029,12 @@ def selectedResChanged(self): c=_all_fm_colors, alpha=0.6, ) + for _line_rts, _line_mzs, _line_col in _fm_group_lines: + if len(_line_rts) >= 2: + _sorted_pts = sorted(zip(_line_mzs, _line_rts)) + _sx = [_rt for _, _rt in _sorted_pts] + _sy = [_mz for _mz, _ in _sorted_pts] + _ax_fg.plot(_sx, _sy, color=_line_col, linewidth=0.8, alpha=0.5, zorder=1) _ax_fg.set_xlabel("Retention time (min)") _ax_fg.set_ylabel("m/z") _n_groups = item.childCount() @@ -8864,6 +8917,9 @@ def selectedResChanged(self): if "FeatureMap" in plotTypes and "feature" not in plotTypes: # Top-level feature map: axes already labelled; just draw canvas self.drawCanvas(self.ui.pl1) + # Connect hover/click handlers so the user can inspect scatter points + _fm_ax = self.ui.pl1.twinxs[0] + self._setup_feature_map_hover(self.ui.pl1, _fm_ax) else: if self.ui.scaleFeatures.checkState() == QtCore.Qt.Checked: self.ui.pl1.axes.set_ylabel("Intensity [counts; normalised]") @@ -9210,6 +9266,105 @@ def _on_click(event): plot_obj._hover_cid = canvas.mpl_connect("motion_notify_event", _on_hover) plot_obj._click_cid = canvas.mpl_connect("button_press_event", _on_click) + def _setup_feature_map_hover(self, plot_obj, ax): + """Connect hover and click handlers to a feature-map scatter canvas. + + On hover: find the closest scatter point (normalised 2-D RT/m/z distance) + and display a popup showing id, metabolite-group, polarity, charge, m/z, + retention time and native abundance. + + On click: same proximity test; if close enough, navigate the sample-results + tree to the corresponding tree item. + + Feature data is read from ``ax._fm_point_data``, a list of dicts with keys: + rt, mz, native_area, id, ogroup, polarity, charge, tree_item + """ + canvas = plot_obj.canvas + + # Disconnect any previous feature-map handlers + for attr in ("_fm_hover_cid", "_fm_click_cid"): + cid = getattr(plot_obj, attr, None) + if cid is not None: + try: + canvas.mpl_disconnect(cid) + except Exception: + pass + plot_obj._fm_hover_cid = None + plot_obj._fm_click_cid = None + plot_obj._fm_hover_artists = [] + + point_data = getattr(ax, "_fm_point_data", []) + if not point_data: + return + + def _find_closest_fm(event_ax, ex, ey): + xlim = event_ax.get_xlim() + ylim = event_ax.get_ylim() + x_range = (xlim[1] - xlim[0]) or 1.0 + y_range = (ylim[1] - ylim[0]) or 1.0 + best_idx, best_dist = 0, float("inf") + for i, pt in enumerate(point_data): + dx = abs(pt["rt"] - ex) / x_range + dy = abs(pt["mz"] - ey) / y_range + d = (dx**2 + dy**2) ** 0.5 + if d < best_dist: + best_dist = d + best_idx = i + return best_idx, best_dist + + def _on_fm_hover(event): + for artist in list(plot_obj._fm_hover_artists): + try: + artist.remove() + except Exception: + pass + plot_obj._fm_hover_artists.clear() + + if event.inaxes is not ax or event.xdata is None or event.ydata is None: + canvas.draw_idle() + return + + cidx, nd = _find_closest_fm(ax, event.xdata, event.ydata) + if nd > 0.04: + canvas.draw_idle() + return + + pt = point_data[cidx] + label = f"ID: {pt['id']}\nGroup: {pt['ogroup']}\nPolarity: {pt['polarity']}\nCharge: {pt['charge']}\nXn: {pt.get('xcount', 'N/A')}\nm/z: {pt['mz']:.5f}\nRT: {pt['rt']:.2f} min\nNative abundance: {pt['native_area']:.1f}" + ann = ax.annotate( + label, + xy=(pt["rt"], pt["mz"]), + xytext=(12, 8), + textcoords="offset points", + fontsize=18, + color="#202124", + bbox=dict(boxstyle="round,pad=0.7", facecolor="lightyellow", edgecolor="#aaaaaa", alpha=0.95), + zorder=10, + ) + plot_obj._fm_hover_artists.append(ann) + canvas.draw_idle() + + def _on_fm_dblclick(event): + if event.dblclick is False or event.inaxes is not ax or event.xdata is None or event.ydata is None: + return + + cidx, nd = _find_closest_fm(ax, event.xdata, event.ydata) + if nd > 0.04: + return + + tree_item = point_data[cidx].get("tree_item") + if tree_item is None: + return + tree = self.ui.res_ExtractedData + parent = tree_item.parent() + if parent is not None: + tree.expandItem(parent) + tree.setCurrentItem(tree_item) + tree.scrollToItem(tree_item) + + plot_obj._fm_hover_cid = canvas.mpl_connect("motion_notify_event", _on_fm_hover) + plot_obj._fm_click_cid = canvas.mpl_connect("button_press_event", _on_fm_dblclick) + def plotSelectedMSMSSpectra(self): """Plot selected MSMS spectra as subplots with shared x-axis""" selected_rows = sorted(set(item.row() for item in self.ui.msms_SpectraList.selectedItems())) diff --git a/src/findIsoPairs.py b/src/findIsoPairs.py index 37b3194..f969ac5 100644 --- a/src/findIsoPairs.py +++ b/src/findIsoPairs.py @@ -2699,6 +2699,11 @@ def groupFeaturePairsUntargetedAndWriteToDB(self, chromPeaks, mzxml, tracer, tra allPeaks[peak.id] = peak peak.correlationsToOthers = [] + # sort by NPeakCenter once so the inner loop can break early on RT distance + chromPeaks = sorted(chromPeaks, key=lambda p: p.NPeakCenter) + + ff_rows = [] # accumulated featurefeatures rows for a single bulk insert + # compare all detected feature pairs at approximately the same retention time for piA in range(len(chromPeaks)): peakA = chromPeaks[piA] @@ -2711,12 +2716,19 @@ def groupFeaturePairsUntargetedAndWriteToDB(self, chromPeaks, mzxml, tracer, tra if peakA.id not in correlations.keys(): correlations[peakA.id] = {} - for peakB in chromPeaks: + for piB in range(piA + 1, len(chromPeaks)): + peakB = chromPeaks[piB] + + # peaks are sorted by NPeakCenter; once the RT gap exceeds the threshold + # all remaining peaks are even further away — safe to stop early + if peakB.NPeakCenter - peakA.NPeakCenter >= self.peakCenterError: + break + if peakB.id not in correlations.keys(): correlations[peakB.id] = {} if peakA.mz < peakB.mz: - if abs(peakA.NPeakCenter - peakB.NPeakCenter) < self.peakCenterError: + if True: # RT check already handled by the sorted early-exit above bmin = int( max( 0, @@ -2783,10 +2795,16 @@ def groupFeaturePairsUntargetedAndWriteToDB(self, chromPeaks, mzxml, tracer, tra logging.error("Error while convoluting two feature pairs, skipping.. (%s)" % str(e)) try: - db_con.insert_row("featurefeatures", {"fID1": peakA.id, "fID2": peakB.id, "corr": pb, "silRatioValue": silRatiosFold}) + ff_rows.append({"fID1": peakA.id, "fID2": peakB.id, "corr": pb, "silRatioValue": silRatiosFold}) except Exception as e: logging.error("Error while convoluting two feature pairs, skipping.. (%s)" % str(e)) - db_con.insert_row("featurefeatures", {"fID1": peakA.id, "fID2": peakB.id, "corr": 0, "silRatioValue": 0}) + ff_rows.append({"fID1": peakA.id, "fID2": peakB.id, "corr": 0, "silRatioValue": 0}) + + # bulk-insert all featurefeatures rows at once instead of one per pair + if ff_rows: + _ff_schema = db_con.tables["featurefeatures"].schema + _ff_new = pl.DataFrame(ff_rows, schema=_ff_schema) + db_con.tables["featurefeatures"] = pl.concat([db_con.tables["featurefeatures"], _ff_new], how="vertical") self.postMessageToProgressWrapper("text", "%s: Convoluting feature groups" % tracer.name) @@ -2796,11 +2814,7 @@ def groupFeaturePairsUntargetedAndWriteToDB(self, chromPeaks, mzxml, tracer, tra delattr(peak, "times") for k in nodes.keys(): - uniq = [] - for u in nodes[k]: - if u not in uniq: - uniq.append(u) - nodes[k] = uniq + nodes[k] = list(set(nodes[k])) # get subgraphs from the feature pair graph. Each subgraph represents one convoluted # feature group @@ -2874,8 +2888,8 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): # print("HCA with", len(cGroups)) gGroup = cGroups.pop(0) - ## TODO optimize this code, it recalculates the computationally expensive HCA too often for a high number of features - if False and len(gGroup) > 100: + ## skip expensive HCA splitting for very large groups — keep them as-is + if len(gGroup) > 100: groups.append(gGroup) continue @@ -2925,24 +2939,57 @@ def checkSubCluster(tree, hca, corrThreshold, cutOffMinRatio): "%s: Annotating feature groups (%d/%d done)" % (tracer.name, done, len(groups)), ) + # collect all per-peak update data and apply in a single batch join-based update + _update_ids = [] + _update_adducts = [] + _update_fDesc = [] + _update_corrToOthers = [] + _update_heteroAtoms = [] + for peak in chromPeaks: adds = countEntries(peak.adducts) peak.adducts = list(adds.keys()) - # Update chromPeaks - db_con.tables["chromPeaks"] = db_con.tables["chromPeaks"].with_columns( - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.adducts)).decode("utf-8"))).otherwise(pl.col("adducts")).alias("adducts"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.fDesc)).decode("utf-8"))).otherwise(pl.col("fDesc")).alias("fDesc"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.correlationsToOthers)).decode("utf-8"))).otherwise(pl.col("correlationsToOthers")).alias("correlationsToOthers"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.heteroAtomsFeaturePairs)).decode("utf-8"))).otherwise(pl.col("heteroAtomsFeaturePairs")).alias("heteroAtomsFeaturePairs"), + _update_ids.append(peak.id) + _update_adducts.append(base64.b64encode(dumps(peak.adducts)).decode("utf-8")) + _update_fDesc.append(base64.b64encode(dumps(peak.fDesc)).decode("utf-8")) + _update_corrToOthers.append(base64.b64encode(dumps(peak.correlationsToOthers)).decode("utf-8")) + _update_heteroAtoms.append(base64.b64encode(dumps(peak.heteroAtomsFeaturePairs)).decode("utf-8")) + + _upd_df = pl.DataFrame( + { + "id": _update_ids, + "_adducts": _update_adducts, + "_fDesc": _update_fDesc, + "_corrToOthers": _update_corrToOthers, + "_heteroAtoms": _update_heteroAtoms, + } + ) + + # single join-based update for chromPeaks + db_con.tables["chromPeaks"] = ( + db_con.tables["chromPeaks"] + .join(_upd_df, on="id", how="left") + .with_columns( + pl.coalesce([pl.col("_adducts"), pl.col("adducts")]).alias("adducts"), + pl.coalesce([pl.col("_fDesc"), pl.col("fDesc")]).alias("fDesc"), + pl.coalesce([pl.col("_corrToOthers"), pl.col("correlationsToOthers")]).alias("correlationsToOthers"), + pl.coalesce([pl.col("_heteroAtoms"), pl.col("heteroAtomsFeaturePairs")]).alias("heteroAtomsFeaturePairs"), ) + .drop(["_adducts", "_fDesc", "_corrToOthers", "_heteroAtoms"]) + ) - # Update allChromPeaks - db_con.tables["allChromPeaks"] = db_con.tables["allChromPeaks"].with_columns( - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.adducts)).decode("utf-8"))).otherwise(pl.col("adducts")).alias("adducts"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.fDesc)).decode("utf-8"))).otherwise(pl.col("fDesc")).alias("fDesc"), - pl.when(pl.col("id") == peak.id).then(pl.lit(base64.b64encode(dumps(peak.heteroAtomsFeaturePairs)).decode("utf-8"))).otherwise(pl.col("heteroAtomsFeaturePairs")).alias("heteroAtomsFeaturePairs"), + # single join-based update for allChromPeaks (no correlationsToOthers column there) + db_con.tables["allChromPeaks"] = ( + db_con.tables["allChromPeaks"] + .join(_upd_df.select(["id", "_adducts", "_fDesc", "_heteroAtoms"]), on="id", how="left") + .with_columns( + pl.coalesce([pl.col("_adducts"), pl.col("adducts")]).alias("adducts"), + pl.coalesce([pl.col("_fDesc"), pl.col("fDesc")]).alias("fDesc"), + pl.coalesce([pl.col("_heteroAtoms"), pl.col("heteroAtomsFeaturePairs")]).alias("heteroAtomsFeaturePairs"), ) + .drop(["_adducts", "_fDesc", "_heteroAtoms"]) + ) # store feature group in the database for group in sorted( diff --git a/src/findIsoPairs_matchPartners.py b/src/findIsoPairs_matchPartners.py index d132034..d0da9c1 100644 --- a/src/findIsoPairs_matchPartners.py +++ b/src/findIsoPairs_matchPartners.py @@ -25,12 +25,12 @@ def calculate_theoretical_ratios(isotopic_enrichment, Xn_max, maxSub): - ''' + """ calculates the theoretical isotopolog distribution for a given isotopic enrichment and number of atoms isotopic_enrichment: isotopic enrichment of the isotope element (e.g. 0.9893 for 12C) Xn_max: maximum number of atoms that can be exchanged (e.g. 3 for 3 carbon atoms) maxSub: maximum number of substitutions - ''' + """ ret = [] ret.append([]) for Xn in range(1, Xn_max + 1): @@ -229,7 +229,7 @@ def matchPartners( (cur_scan.retention_time / 60.0 - startTime) / scan_rt_range, "RT %.2f, found patterns: %d" % (cur_scan.retention_time / 60.0, len(detected_signal_pairs)), ) - + # Compute the scan used to search for labeled signals; may be offset relative to the native scan. # Falls back to curScan when the offset puts the index out of bounds or on a different filter line. # but make sure that the offset is scanIndexOffset scans of the correct filter_line