Skip to content

Commit 7f8812e

Browse files
Add Py_ExperimentalDesign wrapper with tests and README documentation
Co-authored-by: timosachsenberg <[email protected]>
1 parent ea7202e commit 7f8812e

File tree

6 files changed

+540
-1
lines changed

6 files changed

+540
-1
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,39 @@ search engine can be when built with the high-level helpers in
189189
`openms_python`. Reuse the test as inspiration for bespoke pipelines or as a
190190
regression harness when experimenting with search-related utilities.
191191

192+
## Experimental design support
193+
194+
Managing multi-sample, multi-fraction experiments? The `Py_ExperimentalDesign`
195+
wrapper makes it straightforward to work with OpenMS experimental design files
196+
that describe sample layouts, fractionation schemes, and labeling strategies.
197+
`tests/test_py_experimentaldesign.py` provides comprehensive examples of loading
198+
and querying experimental designs, including support for fractionated workflows,
199+
label-free and labeled quantitation setups, and integration with feature maps,
200+
consensus maps, and identification results. The wrapper exposes Pythonic
201+
properties for quick access to sample counts, fraction information, and design
202+
summaries—perfect for building sample-aware quantitation pipelines or validating
203+
experimental metadata before analysis.
204+
205+
```python
206+
from openms_python import Py_ExperimentalDesign
207+
208+
# Load an experimental design from a TSV file
209+
design = Py_ExperimentalDesign.from_file("design.tsv")
210+
211+
# Quick access to design properties
212+
print(f"Samples: {design.n_samples}")
213+
print(f"MS files: {design.n_ms_files}")
214+
print(f"Fractionated: {design.is_fractionated}")
215+
216+
# Get a summary
217+
design.print_summary()
218+
219+
# Create from existing OpenMS objects
220+
from openms_python import Py_ConsensusMap
221+
consensus = Py_ConsensusMap.from_file("results.consensusXML")
222+
design = Py_ExperimentalDesign.from_consensus_map(consensus)
223+
```
224+
192225
### Iterate over containers and metadata
193226

194227
All sequence-like wrappers (feature maps, consensus maps, identification containers,

openms_python/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .py_feature import Py_Feature
2727
from .py_featuremap import Py_FeatureMap
2828
from .py_consensusmap import Py_ConsensusMap
29+
from .py_experimentaldesign import Py_ExperimentalDesign
2930
from .py_identifications import (
3031
ProteinIdentifications,
3132
PeptideIdentifications,
@@ -101,6 +102,7 @@ def get_example(name: str, *, load: bool = False, target_dir: Union[str, Path, N
101102
"Py_Feature",
102103
"Py_FeatureMap",
103104
"Py_ConsensusMap",
105+
"Py_ExperimentalDesign",
104106
"ProteinIdentifications",
105107
"PeptideIdentifications",
106108
"Identifications",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fraction_Group Fraction Spectra_Filepath Label Sample
2+
1 1 sample1_fraction1.mzML 1 1
3+
1 2 sample1_fraction2.mzML 1 1
4+
1 3 sample1_fraction3.mzML 1 1
5+
2 1 sample2_fraction1.mzML 1 2
6+
2 2 sample2_fraction2.mzML 1 2
7+
2 3 sample2_fraction3.mzML 1 2
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
"""Pythonic wrapper for :class:`pyopenms.ExperimentalDesign`."""
2+
from __future__ import annotations
3+
4+
from pathlib import Path
5+
from typing import Union, Optional, Set
6+
import pandas as pd
7+
8+
import pyopenms as oms
9+
10+
from ._io_utils import ensure_allowed_suffix
11+
12+
# Supported file extensions for experimental design
13+
EXPERIMENTAL_DESIGN_EXTENSIONS = {".tsv"}
14+
15+
16+
class Py_ExperimentalDesign:
17+
"""A Pythonic wrapper around :class:`pyopenms.ExperimentalDesign`.
18+
19+
This class provides convenient methods for loading, storing, and working with
20+
experimental design files in OpenMS format.
21+
22+
Example:
23+
>>> from openms_python import Py_ExperimentalDesign
24+
>>> design = Py_ExperimentalDesign.from_file("design.tsv")
25+
>>> print(f"Samples: {design.n_samples}, MS files: {design.n_ms_files}")
26+
"""
27+
28+
def __init__(self, native_design: Optional[oms.ExperimentalDesign] = None):
29+
"""Initialize with an optional native ExperimentalDesign object.
30+
31+
Parameters
32+
----------
33+
native_design:
34+
Optional :class:`pyopenms.ExperimentalDesign` to wrap.
35+
"""
36+
self._design = native_design if native_design is not None else oms.ExperimentalDesign()
37+
38+
@classmethod
39+
def from_file(cls, filepath: Union[str, Path]) -> 'Py_ExperimentalDesign':
40+
"""Load an experimental design from a TSV file.
41+
42+
Parameters
43+
----------
44+
filepath:
45+
Path to the experimental design TSV file.
46+
47+
Returns
48+
-------
49+
Py_ExperimentalDesign
50+
A new instance with the loaded design.
51+
52+
Example:
53+
>>> design = Py_ExperimentalDesign.from_file("design.tsv")
54+
"""
55+
instance = cls()
56+
instance.load(filepath)
57+
return instance
58+
59+
def load(self, filepath: Union[str, Path]) -> 'Py_ExperimentalDesign':
60+
"""Load an experimental design from disk.
61+
62+
Parameters
63+
----------
64+
filepath:
65+
Path to the experimental design TSV file.
66+
67+
Returns
68+
-------
69+
Py_ExperimentalDesign
70+
Self for method chaining.
71+
"""
72+
ensure_allowed_suffix(filepath, EXPERIMENTAL_DESIGN_EXTENSIONS, "ExperimentalDesign")
73+
edf = oms.ExperimentalDesignFile()
74+
self._design = edf.load(str(filepath), False)
75+
return self
76+
77+
def store(self, filepath: Union[str, Path]) -> 'Py_ExperimentalDesign':
78+
"""Store the experimental design to disk.
79+
80+
Note: Storage functionality is not available in the current pyOpenMS API.
81+
This method is provided for API consistency but will raise NotImplementedError.
82+
83+
Parameters
84+
----------
85+
filepath:
86+
Path where the experimental design should be saved.
87+
88+
Returns
89+
-------
90+
Py_ExperimentalDesign
91+
Self for method chaining.
92+
93+
Raises
94+
------
95+
NotImplementedError
96+
Storage is not yet implemented in pyOpenMS.
97+
"""
98+
ensure_allowed_suffix(filepath, EXPERIMENTAL_DESIGN_EXTENSIONS, "ExperimentalDesign")
99+
raise NotImplementedError(
100+
"ExperimentalDesign storage is not yet available in pyOpenMS. "
101+
"Please use the native pyOpenMS API if this functionality is needed."
102+
)
103+
104+
@property
105+
def native(self) -> oms.ExperimentalDesign:
106+
"""Return the underlying :class:`pyopenms.ExperimentalDesign`."""
107+
return self._design
108+
109+
# ==================== Properties ====================
110+
111+
@property
112+
def n_samples(self) -> int:
113+
"""Number of samples in the experimental design."""
114+
return self._design.getNumberOfSamples()
115+
116+
@property
117+
def n_ms_files(self) -> int:
118+
"""Number of MS files in the experimental design."""
119+
return self._design.getNumberOfMSFiles()
120+
121+
@property
122+
def n_fractions(self) -> int:
123+
"""Number of fractions in the experimental design."""
124+
return self._design.getNumberOfFractions()
125+
126+
@property
127+
def n_fraction_groups(self) -> int:
128+
"""Number of fraction groups in the experimental design."""
129+
return self._design.getNumberOfFractionGroups()
130+
131+
@property
132+
def n_labels(self) -> int:
133+
"""Number of labels in the experimental design."""
134+
return self._design.getNumberOfLabels()
135+
136+
@property
137+
def is_fractionated(self) -> bool:
138+
"""Whether the experimental design includes fractionation."""
139+
return self._design.isFractionated()
140+
141+
@property
142+
def same_n_ms_files_per_fraction(self) -> bool:
143+
"""Whether all fractions have the same number of MS files."""
144+
return self._design.sameNrOfMSFilesPerFraction()
145+
146+
@property
147+
def samples(self) -> Set[str]:
148+
"""Set of sample identifiers in the design.
149+
150+
Returns
151+
-------
152+
Set[str]
153+
Set of sample identifiers.
154+
"""
155+
sample_section = self._design.getSampleSection()
156+
samples = sample_section.getSamples()
157+
# Convert bytes to str if needed
158+
return {s.decode() if isinstance(s, bytes) else str(s) for s in samples}
159+
160+
# ==================== Summary methods ====================
161+
162+
def summary(self) -> dict:
163+
"""Get a summary of the experimental design.
164+
165+
Returns
166+
-------
167+
dict
168+
Dictionary with summary statistics.
169+
"""
170+
return {
171+
'n_samples': self.n_samples,
172+
'n_ms_files': self.n_ms_files,
173+
'n_fractions': self.n_fractions,
174+
'n_fraction_groups': self.n_fraction_groups,
175+
'n_labels': self.n_labels,
176+
'is_fractionated': self.is_fractionated,
177+
'samples': sorted(self.samples),
178+
}
179+
180+
def print_summary(self) -> None:
181+
"""Print a formatted summary of the experimental design."""
182+
summary = self.summary()
183+
print("Experimental Design Summary")
184+
print("=" * 40)
185+
print(f"Samples: {summary['n_samples']}")
186+
print(f"MS Files: {summary['n_ms_files']}")
187+
print(f"Fractions: {summary['n_fractions']}")
188+
print(f"Fraction Groups: {summary['n_fraction_groups']}")
189+
print(f"Labels: {summary['n_labels']}")
190+
print(f"Fractionated: {summary['is_fractionated']}")
191+
if summary['samples']:
192+
print(f"Sample IDs: {', '.join(summary['samples'])}")
193+
194+
# ==================== Factory methods ====================
195+
196+
@classmethod
197+
def from_consensus_map(cls, consensus_map: Union['Py_ConsensusMap', oms.ConsensusMap]) -> 'Py_ExperimentalDesign':
198+
"""Create an ExperimentalDesign from a ConsensusMap.
199+
200+
Parameters
201+
----------
202+
consensus_map:
203+
A :class:`Py_ConsensusMap` or :class:`pyopenms.ConsensusMap`.
204+
205+
Returns
206+
-------
207+
Py_ExperimentalDesign
208+
A new instance derived from the consensus map.
209+
"""
210+
# Handle both Py_ConsensusMap and native ConsensusMap
211+
native_map = consensus_map.native if hasattr(consensus_map, 'native') else consensus_map
212+
design = oms.ExperimentalDesign.fromConsensusMap(native_map)
213+
return cls(design)
214+
215+
@classmethod
216+
def from_feature_map(cls, feature_map: Union['Py_FeatureMap', oms.FeatureMap]) -> 'Py_ExperimentalDesign':
217+
"""Create an ExperimentalDesign from a FeatureMap.
218+
219+
Parameters
220+
----------
221+
feature_map:
222+
A :class:`Py_FeatureMap` or :class:`pyopenms.FeatureMap`.
223+
224+
Returns
225+
-------
226+
Py_ExperimentalDesign
227+
A new instance derived from the feature map.
228+
"""
229+
# Handle both Py_FeatureMap and native FeatureMap
230+
native_map = feature_map.native if hasattr(feature_map, 'native') else feature_map
231+
design = oms.ExperimentalDesign.fromFeatureMap(native_map)
232+
return cls(design)
233+
234+
@classmethod
235+
def from_identifications(
236+
cls,
237+
protein_ids: list
238+
) -> 'Py_ExperimentalDesign':
239+
"""Create an ExperimentalDesign from protein identification data.
240+
241+
Parameters
242+
----------
243+
protein_ids:
244+
List of :class:`pyopenms.ProteinIdentification` objects.
245+
246+
Returns
247+
-------
248+
Py_ExperimentalDesign
249+
A new instance derived from the identifications.
250+
"""
251+
design = oms.ExperimentalDesign.fromIdentifications(protein_ids)
252+
return cls(design)
253+
254+
# ==================== Delegation ====================
255+
256+
def __getattr__(self, name: str):
257+
"""Delegate attribute access to the underlying ExperimentalDesign."""
258+
return getattr(self._design, name)
259+
260+
def __repr__(self) -> str:
261+
"""String representation of the ExperimentalDesign."""
262+
return (
263+
f"Py_ExperimentalDesign(samples={self.n_samples}, "
264+
f"ms_files={self.n_ms_files}, fractionated={self.is_fractionated})"
265+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ where = ["."]
5050
include = ["openms_python*"]
5151

5252
[tool.setuptools.package-data]
53-
"openms_python" = ["examples/*.mzML"]
53+
"openms_python" = ["examples/*.mzML", "examples/*.tsv"]
5454

5555
[tool.black]
5656
line-length = 100

0 commit comments

Comments
 (0)