562 lines
16 KiB
Markdown
562 lines
16 KiB
Markdown
---
|
|
title: "Metabolomics Workflows"
|
|
task: ""
|
|
lineage_type: import
|
|
upstream_source: https://github.com/K-Dense-AI/scientific-agent-skills/blob/9c9bd2e9/skills/pyopenms/references/metabolomics.md
|
|
upstream_sha: 9c9bd2e9
|
|
imported_at: 2026-06-27
|
|
prompt_class: prompt
|
|
upstream_changes: accepted
|
|
author: upstream
|
|
validated: false
|
|
---
|
|
|
|
# Metabolomics Workflows
|
|
|
|
## Overview
|
|
|
|
PyOpenMS provides specialized tools for untargeted metabolomics analysis including feature detection optimized for small molecules, adduct grouping, compound identification, and integration with metabolomics databases.
|
|
|
|
> **Code examples target pyOpenMS 3.5.0.** APIs removed in 3.5.0 (e.g. `FeatureFinder().run("centroided", ...)` and `MetaboliteAdductDecharger`) are not used here. The skill also ships ready-to-run scripts implementing these workflows end-to-end: `scripts/detect_features_metabo.py`, `scripts/align_link_quantify.py`, `scripts/consensus_to_matrix.py`, `scripts/detect_adducts.py`, `scripts/accurate_mass_search.py`, and `scripts/export_gnps_sirius.py`.
|
|
|
|
## Untargeted Metabolomics Pipeline
|
|
|
|
### Complete Workflow
|
|
|
|
```python
|
|
import pyopenms as ms
|
|
|
|
def metabolomics_pipeline(input_files, output_dir):
|
|
"""
|
|
Complete untargeted metabolomics workflow.
|
|
|
|
Args:
|
|
input_files: List of mzML file paths (one per sample)
|
|
output_dir: Directory for output files
|
|
"""
|
|
|
|
# Step 1: Feature detection (MassTraceDetection ->
|
|
# ElutionPeakDetection -> FeatureFindingMetabo)
|
|
feature_maps = []
|
|
|
|
for mzml_file in input_files:
|
|
print(f"Processing {mzml_file}...")
|
|
|
|
# Load data
|
|
exp = ms.MSExperiment()
|
|
ms.MzMLFile().load(mzml_file, exp)
|
|
exp.sortSpectra(True)
|
|
|
|
# Detect mass traces
|
|
mtd = ms.MassTraceDetection()
|
|
p = mtd.getDefaults()
|
|
p.setValue("mass_error_ppm", 10.0)
|
|
p.setValue("noise_threshold_int", 1000.0)
|
|
mtd.setParameters(p)
|
|
mass_traces = []
|
|
mtd.run(exp, mass_traces, 0)
|
|
|
|
# Split traces into elution peaks
|
|
epd = ms.ElutionPeakDetection()
|
|
p = epd.getDefaults()
|
|
p.setValue("width_filtering", "fixed")
|
|
epd.setParameters(p)
|
|
mt_split = []
|
|
epd.detectPeaks(mass_traces, mt_split)
|
|
|
|
# Assemble metabolite features
|
|
ffm = ms.FeatureFindingMetabo()
|
|
p = ffm.getDefaults()
|
|
p.setValue("isotope_filtering_model", "metabolites (5% RMS)")
|
|
p.setValue("remove_single_traces", "true")
|
|
p.setValue("charge_lower_bound", 1)
|
|
p.setValue("charge_upper_bound", 3)
|
|
ffm.setParameters(p)
|
|
features = ms.FeatureMap()
|
|
chrom_out = []
|
|
ffm.run(mt_split, features, chrom_out)
|
|
features.setUniqueIds()
|
|
|
|
features.setPrimaryMSRunPath([mzml_file.encode()])
|
|
feature_maps.append(features)
|
|
|
|
print(f" Detected {features.size()} features")
|
|
|
|
# Step 2: Adduct detection and grouping
|
|
print("Detecting adducts...")
|
|
adduct_grouped_maps = []
|
|
|
|
for fm in feature_maps:
|
|
mfd = ms.MetaboliteFeatureDeconvolution()
|
|
p = mfd.getDefaults()
|
|
# potential_adducts uses Elements:Charge:Probability syntax
|
|
p.setValue("potential_adducts",
|
|
[b"H:+:0.4", b"Na:+:0.25", b"NH4:+:0.25",
|
|
b"K:+:0.1", b"H-2O-1:0:0.05"])
|
|
p.setValue("charge_min", 1)
|
|
p.setValue("charge_max", 1)
|
|
mfd.setParameters(p)
|
|
|
|
fm_out = ms.FeatureMap()
|
|
groups = ms.ConsensusMap()
|
|
edges = ms.ConsensusMap()
|
|
mfd.compute(fm, fm_out, groups, edges) # 4 args
|
|
adduct_grouped_maps.append(fm_out)
|
|
|
|
# Step 3: RT alignment (PoseClustering: pick a reference,
|
|
# then align each map in place against it)
|
|
print("Aligning retention times...")
|
|
aligner = ms.MapAlignmentAlgorithmPoseClustering()
|
|
reference = adduct_grouped_maps[0]
|
|
aligner.setReference(reference)
|
|
|
|
transformer = ms.MapAlignmentTransformer()
|
|
for fm in adduct_grouped_maps:
|
|
trafo = ms.TransformationDescription()
|
|
aligner.align(fm, trafo)
|
|
transformer.transformRetentionTimes(fm, trafo, True)
|
|
|
|
aligned_maps = adduct_grouped_maps
|
|
|
|
# Step 4: Feature linking
|
|
print("Linking features...")
|
|
grouper = ms.FeatureGroupingAlgorithmQT()
|
|
|
|
params = grouper.getParameters()
|
|
params.setValue("distance_RT:max_difference", 60.0) # seconds
|
|
params.setValue("distance_MZ:max_difference", 5.0) # ppm
|
|
params.setValue("distance_MZ:unit", "ppm")
|
|
grouper.setParameters(params)
|
|
|
|
consensus_map = ms.ConsensusMap()
|
|
grouper.group(aligned_maps, consensus_map) # list of FeatureMap
|
|
consensus_map.setUniqueIds()
|
|
|
|
print(f"Created {consensus_map.size()} consensus features")
|
|
|
|
# Step 5: Export results
|
|
consensus_file = f"{output_dir}/consensus.consensusXML"
|
|
ms.ConsensusXMLFile().store(consensus_file, consensus_map)
|
|
|
|
# Export quant matrix for downstream analysis
|
|
# get_intensity_df() -> features x samples; get_metadata_df() -> rt/mz/charge/quality
|
|
intensities = consensus_map.get_intensity_df()
|
|
metadata = consensus_map.get_metadata_df()
|
|
csv_file = f"{output_dir}/metabolite_table.csv"
|
|
metadata.join(intensities).to_csv(csv_file)
|
|
|
|
print(f"Results saved to {output_dir}")
|
|
|
|
return consensus_map
|
|
|
|
# Run pipeline
|
|
input_files = ["sample1.mzML", "sample2.mzML", "sample3.mzML"]
|
|
consensus = metabolomics_pipeline(input_files, "output")
|
|
```
|
|
|
|
## Adduct Detection
|
|
|
|
### Configure Adduct Types
|
|
|
|
Adducts are configured with the `Elements:Charge:Probability` syntax (e.g.
|
|
`b"Na:+:0.25"`), not bracket notation like `[M+Na]+`. Probabilities across the
|
|
list should sum to ~1.0.
|
|
|
|
```python
|
|
# Create adduct detector
|
|
mfd = ms.MetaboliteFeatureDeconvolution()
|
|
|
|
# Configure common adducts
|
|
params = mfd.getDefaults()
|
|
|
|
# Positive mode adducts (Elements:Charge:Probability)
|
|
positive_adducts = [
|
|
b"H:+:0.4",
|
|
b"Na:+:0.25",
|
|
b"NH4:+:0.25",
|
|
b"K:+:0.1",
|
|
b"H-2O-1:0:0.05", # neutral water loss
|
|
]
|
|
|
|
# Negative mode adducts (set negative_mode="true" when using these)
|
|
negative_adducts = [
|
|
b"H-1:-:0.6",
|
|
b"Cl:-:0.2",
|
|
]
|
|
|
|
# Set for positive mode
|
|
params.setValue("potential_adducts", positive_adducts)
|
|
params.setValue("charge_min", 1)
|
|
params.setValue("charge_max", 1)
|
|
mfd.setParameters(params)
|
|
|
|
# Apply adduct detection (4 args: in, out, groups, edges)
|
|
feature_map_out = ms.FeatureMap()
|
|
groups = ms.ConsensusMap()
|
|
edges = ms.ConsensusMap()
|
|
mfd.compute(feature_map, feature_map_out, groups, edges)
|
|
```
|
|
|
|
### Access Adduct Information
|
|
|
|
```python
|
|
# Check adduct annotations
|
|
for feature in feature_map_out:
|
|
# Get adduct type if annotated
|
|
if feature.metaValueExists("adduct"):
|
|
adduct = feature.getMetaValue("adduct")
|
|
neutral_mass = feature.getMetaValue("neutral_mass")
|
|
print(f"m/z: {feature.getMZ():.4f}")
|
|
print(f" Adduct: {adduct}")
|
|
print(f" Neutral mass: {neutral_mass:.4f}")
|
|
```
|
|
|
|
## Compound Identification
|
|
|
|
### Accurate Mass Search (HMDB)
|
|
|
|
The built-in `AccurateMassSearchEngine` annotates a `FeatureMap` against HMDB
|
|
and writes results as mzTab.
|
|
|
|
> **Caveat:** the pip wheel ships `HMDBMappingFile.tsv` but **not**
|
|
> `HMDB2StructMapping.tsv`, so `engine.init()` fails unless you supply the
|
|
> struct file yourself. Download it from
|
|
> https://github.com/OpenMS/OpenMS/blob/develop/share/OpenMS/CHEMISTRY/HMDB2StructMapping.tsv
|
|
> and point `db:struct` at it.
|
|
|
|
```python
|
|
engine = ms.AccurateMassSearchEngine()
|
|
p = engine.getDefaults()
|
|
p.setValue("mass_error_value", 5.0)
|
|
p.setValue("mass_error_unit", "ppm")
|
|
p.setValue("ionization_mode", "positive")
|
|
# If the struct file is missing from the wheel, supply it:
|
|
# p.setValue("db:struct", b"/path/to/HMDB2StructMapping.tsv")
|
|
engine.setParameters(p)
|
|
engine.init()
|
|
|
|
mztab = ms.MzTab()
|
|
engine.run(feature_map, mztab)
|
|
ms.MzTabFile().store("out.mzTab", mztab)
|
|
```
|
|
|
|
### Mass-Based Annotation (custom database)
|
|
|
|
```python
|
|
# Annotate features against your own compound list.
|
|
# (Plain Python, no pyOpenMS-specific DB required.)
|
|
|
|
# Load compound database (example structure)
|
|
# In practice, use external database like HMDB, METLIN
|
|
|
|
compound_db = [
|
|
{"name": "Glucose", "formula": "C6H12O6", "mass": 180.0634},
|
|
{"name": "Citric acid", "formula": "C6H8O7", "mass": 192.0270},
|
|
# ... more compounds
|
|
]
|
|
|
|
# Annotate features
|
|
mass_tolerance = 5.0 # ppm
|
|
|
|
for feature in feature_map:
|
|
observed_mz = feature.getMZ()
|
|
|
|
# Calculate neutral mass (assuming [M+H]+)
|
|
neutral_mass = observed_mz - 1.007276 # Proton mass
|
|
|
|
# Search database
|
|
for compound in compound_db:
|
|
mass_error_ppm = abs(neutral_mass - compound["mass"]) / compound["mass"] * 1e6
|
|
|
|
if mass_error_ppm <= mass_tolerance:
|
|
print(f"Potential match: {compound['name']}")
|
|
print(f" Observed m/z: {observed_mz:.4f}")
|
|
print(f" Expected mass: {compound['mass']:.4f}")
|
|
print(f" Error: {mass_error_ppm:.2f} ppm")
|
|
```
|
|
|
|
### MS/MS-Based Identification
|
|
|
|
```python
|
|
# Load MS2 data
|
|
exp = ms.MSExperiment()
|
|
ms.MzMLFile().load("data_with_ms2.mzML", exp)
|
|
|
|
# Extract MS2 spectra
|
|
ms2_spectra = []
|
|
for spec in exp:
|
|
if spec.getMSLevel() == 2:
|
|
ms2_spectra.append(spec)
|
|
|
|
print(f"Found {len(ms2_spectra)} MS2 spectra")
|
|
|
|
# Match to spectral library
|
|
# (Requires external tool or custom implementation)
|
|
```
|
|
|
|
## Data Normalization
|
|
|
|
### Total Ion Current (TIC) Normalization
|
|
|
|
```python
|
|
import numpy as np
|
|
|
|
# Load consensus map
|
|
consensus_map = ms.ConsensusMap()
|
|
ms.ConsensusXMLFile().load("consensus.consensusXML", consensus_map)
|
|
|
|
# Calculate TIC per sample
|
|
n_samples = len(consensus_map.getColumnHeaders())
|
|
tic_per_sample = np.zeros(n_samples)
|
|
|
|
for cons_feature in consensus_map:
|
|
for handle in cons_feature.getFeatureList():
|
|
map_idx = handle.getMapIndex()
|
|
tic_per_sample[map_idx] += handle.getIntensity()
|
|
|
|
print("TIC per sample:", tic_per_sample)
|
|
|
|
# Normalize to median TIC
|
|
median_tic = np.median(tic_per_sample)
|
|
normalization_factors = median_tic / tic_per_sample
|
|
|
|
print("Normalization factors:", normalization_factors)
|
|
|
|
# Apply normalization
|
|
consensus_map_normalized = ms.ConsensusMap(consensus_map)
|
|
for cons_feature in consensus_map_normalized:
|
|
feature_list = cons_feature.getFeatureList()
|
|
for handle in feature_list:
|
|
map_idx = handle.getMapIndex()
|
|
normalized_intensity = handle.getIntensity() * normalization_factors[map_idx]
|
|
handle.setIntensity(normalized_intensity)
|
|
```
|
|
|
|
## Quality Control
|
|
|
|
### Coefficient of Variation (CV) Filtering
|
|
|
|
```python
|
|
import pandas as pd
|
|
import numpy as np
|
|
|
|
# Export intensities to pandas (features x samples)
|
|
df = consensus_map.get_intensity_df()
|
|
|
|
# Assume QC samples are columns with 'QC' in name
|
|
qc_cols = [col for col in df.columns if 'QC' in col]
|
|
|
|
if qc_cols:
|
|
# Calculate CV for each feature in QC samples
|
|
qc_data = df[qc_cols]
|
|
cv = (qc_data.std(axis=1) / qc_data.mean(axis=1)) * 100
|
|
|
|
# Filter features with CV < 30% in QC samples
|
|
good_features = df[cv < 30]
|
|
|
|
print(f"Features before CV filter: {len(df)}")
|
|
print(f"Features after CV filter: {len(good_features)}")
|
|
```
|
|
|
|
### Blank Filtering
|
|
|
|
```python
|
|
# Remove features present in blank samples
|
|
blank_cols = [col for col in df.columns if 'Blank' in col]
|
|
sample_cols = [col for col in df.columns if 'Sample' in col]
|
|
|
|
if blank_cols and sample_cols:
|
|
# Calculate mean intensity in blanks and samples
|
|
blank_mean = df[blank_cols].mean(axis=1)
|
|
sample_mean = df[sample_cols].mean(axis=1)
|
|
|
|
# Keep features with 3x higher intensity in samples than blanks
|
|
ratio = sample_mean / (blank_mean + 1) # Add 1 to avoid division by zero
|
|
filtered_df = df[ratio > 3]
|
|
|
|
print(f"Features before blank filtering: {len(df)}")
|
|
print(f"Features after blank filtering: {len(filtered_df)}")
|
|
```
|
|
|
|
## Missing Value Imputation
|
|
|
|
```python
|
|
import pandas as pd
|
|
import numpy as np
|
|
|
|
# Load intensities (features x samples)
|
|
df = consensus_map.get_intensity_df()
|
|
|
|
# Replace zeros with NaN
|
|
df = df.replace(0, np.nan)
|
|
|
|
# Count missing values
|
|
missing_per_feature = df.isnull().sum(axis=1)
|
|
print(f"Features with >50% missing: {sum(missing_per_feature > len(df.columns)/2)}")
|
|
|
|
# Simple imputation: replace with minimum value
|
|
for col in df.columns:
|
|
if df[col].dtype in [np.float64, np.int64]:
|
|
min_val = df[col].min() / 2 # Half minimum
|
|
df[col].fillna(min_val, inplace=True)
|
|
```
|
|
|
|
## Metabolite Table Export
|
|
|
|
### Create Analysis-Ready Table
|
|
|
|
```python
|
|
import pandas as pd
|
|
|
|
def create_metabolite_table(consensus_map, output_file):
|
|
"""
|
|
Create metabolite quantification table for statistical analysis.
|
|
"""
|
|
|
|
# Get column headers (file descriptions)
|
|
headers = consensus_map.getColumnHeaders()
|
|
|
|
# Initialize data structure
|
|
data = {
|
|
'mz': [],
|
|
'rt': [],
|
|
'feature_id': []
|
|
}
|
|
|
|
# Add sample columns
|
|
for map_idx, header in headers.items():
|
|
sample_name = header.label or f"Sample_{map_idx}"
|
|
data[sample_name] = []
|
|
|
|
# Extract feature data
|
|
for idx, cons_feature in enumerate(consensus_map):
|
|
data['mz'].append(cons_feature.getMZ())
|
|
data['rt'].append(cons_feature.getRT())
|
|
data['feature_id'].append(f"F{idx:06d}")
|
|
|
|
# Initialize intensities
|
|
intensities = {map_idx: 0.0 for map_idx in headers.keys()}
|
|
|
|
# Fill in measured intensities
|
|
for handle in cons_feature.getFeatureList():
|
|
map_idx = handle.getMapIndex()
|
|
intensities[map_idx] = handle.getIntensity()
|
|
|
|
# Add to data structure
|
|
for map_idx, header in headers.items():
|
|
sample_name = header.label or f"Sample_{map_idx}"
|
|
data[sample_name].append(intensities[map_idx])
|
|
|
|
# Create DataFrame
|
|
df = pd.DataFrame(data)
|
|
|
|
# Sort by RT
|
|
df = df.sort_values('rt')
|
|
|
|
# Save to CSV
|
|
df.to_csv(output_file, index=False)
|
|
|
|
print(f"Metabolite table with {len(df)} features saved to {output_file}")
|
|
|
|
return df
|
|
|
|
# Create table
|
|
df = create_metabolite_table(consensus_map, "metabolite_table.csv")
|
|
```
|
|
|
|
## Integration with External Tools
|
|
|
|
### Export for MetaboAnalyst
|
|
|
|
```python
|
|
def export_for_metaboanalyst(df, output_file):
|
|
"""
|
|
Format data for MetaboAnalyst input.
|
|
|
|
Requires sample names as columns, features as rows.
|
|
"""
|
|
|
|
# Transpose DataFrame
|
|
# Remove metadata columns
|
|
sample_cols = [col for col in df.columns if col not in ['mz', 'rt', 'feature_id']]
|
|
|
|
# Extract sample data
|
|
sample_data = df[sample_cols]
|
|
|
|
# Transpose (samples as rows, features as columns)
|
|
df_transposed = sample_data.T
|
|
|
|
# Add feature identifiers as column names
|
|
df_transposed.columns = df['feature_id']
|
|
|
|
# Save
|
|
df_transposed.to_csv(output_file)
|
|
|
|
print(f"MetaboAnalyst format saved to {output_file}")
|
|
|
|
# Export
|
|
export_for_metaboanalyst(df, "for_metaboanalyst.csv")
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Sample Size and Replicates
|
|
|
|
- Include QC samples (pooled sample) every 5-10 injections
|
|
- Run blank samples to identify contamination
|
|
- Use at least 3 biological replicates per group
|
|
- Randomize sample injection order
|
|
|
|
### Parameter Optimization
|
|
|
|
Test parameters on pooled QC sample:
|
|
|
|
```python
|
|
# Test different mass trace detection parameters
|
|
mass_errors_ppm = [3.0, 5.0, 10.0]
|
|
noise_thresholds = [500.0, 1000.0, 2000.0]
|
|
|
|
exp.sortSpectra(True)
|
|
|
|
for mass_err in mass_errors_ppm:
|
|
for noise in noise_thresholds:
|
|
mtd = ms.MassTraceDetection()
|
|
p = mtd.getDefaults()
|
|
p.setValue("mass_error_ppm", mass_err)
|
|
p.setValue("noise_threshold_int", noise)
|
|
mtd.setParameters(p)
|
|
mass_traces = []
|
|
mtd.run(exp, mass_traces, 0)
|
|
|
|
epd = ms.ElutionPeakDetection()
|
|
p = epd.getDefaults()
|
|
p.setValue("width_filtering", "fixed")
|
|
epd.setParameters(p)
|
|
mt_split = []
|
|
epd.detectPeaks(mass_traces, mt_split)
|
|
|
|
ffm = ms.FeatureFindingMetabo()
|
|
p = ffm.getDefaults()
|
|
p.setValue("isotope_filtering_model", "metabolites (5% RMS)")
|
|
p.setValue("remove_single_traces", "true")
|
|
ffm.setParameters(p)
|
|
features = ms.FeatureMap()
|
|
chrom_out = []
|
|
ffm.run(mt_split, features, chrom_out)
|
|
|
|
print(f"mass_error_ppm={mass_err}, noise={noise}: "
|
|
f"{features.size()} features")
|
|
```
|
|
|
|
### Retention Time Windows
|
|
|
|
Adjust based on chromatographic method:
|
|
|
|
```python
|
|
# For 10-minute LC gradient
|
|
params.setValue("distance_RT:max_difference", 30.0) # 30 seconds
|
|
|
|
# For 60-minute LC gradient
|
|
params.setValue("distance_RT:max_difference", 90.0) # 90 seconds
|
|
```
|