diff --git a/README.md b/README.md index 1a2ecb8..2a6fa54 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ helper performs three steps: 1. Copies the incoming feature maps to avoid mutating your data 2. Aligns the feature maps with your choice of OpenMS alignment algorithm -3. Links the aligned runs using `FeatureGroupingAlgorithmQT` +3. Links the aligned runs using your choice of feature grouping algorithm ```python from openms_python import Py_FeatureMap, Py_ConsensusMap @@ -137,6 +137,8 @@ consensus = Py_ConsensusMap.align_and_link( feature_maps, alignment_method="pose_clustering", # or "identification" / "identity" alignment_params={"max_rt_shift": 15.0}, + grouping_method="qt", # or "kd" / "labeled" / "unlabeled" (default: "qt") + grouping_params={"distance_RT:max_difference": 100.0}, ) print(f"Consensus contains {len(consensus)} features") @@ -326,7 +328,11 @@ annotated = map_identifications_to_features(feature_map, filtered) # 3) Align multiple maps and link them into a consensus representation aligned = align_feature_maps([annotated, second_run]) -consensus = link_features(aligned) +consensus = link_features( + aligned, + grouping_method="qt", # or "kd" / "labeled" / "unlabeled" + params={"distance_RT:max_difference": 100.0} +) # 4) Export a tidy quantitation table (per-sample intensities) quant_df = export_quant_table(consensus) @@ -355,7 +361,10 @@ picker.pickExperiment(exp, centroided, True) ```python from openms_python import Py_MSExperiment -centroided = exp.pick_peaks(method="HiRes", params={"signal_to_noise": 3.0}) +# Choose from multiple peak picking algorithms +centroided = exp.pick_peaks(method="hires", params={"signal_to_noise": 3.0}) +# Available methods: "hires" (default), "cwt", "iterative" + # or modify in-place exp.pick_peaks(inplace=True) ``` diff --git a/openms_python/py_consensusmap.py b/openms_python/py_consensusmap.py index 614e3bd..a8d8942 100644 --- a/openms_python/py_consensusmap.py +++ b/openms_python/py_consensusmap.py @@ -164,6 +164,8 @@ def align_and_link( *, alignment_method: str = "pose_clustering", alignment_params: Optional[Dict[str, Union[int, float, str]]] = None, + grouping_method: str = "qt", + grouping_params: Optional[Dict[str, Union[int, float, str]]] = None, ) -> 'Py_ConsensusMap': """Align multiple feature maps and return their linked consensus map. @@ -180,6 +182,13 @@ def align_and_link( alignment_params: Optional dictionary of parameters applied to the selected alignment algorithm. + grouping_method: + Name of the OpenMS feature grouping algorithm to use. Supported values + are ``"qt"`` (QT clustering, default), ``"kd"`` (KD-tree based), + ``"labeled"`` (for labeled data), and ``"unlabeled"`` (for unlabeled data). + grouping_params: + Optional dictionary of parameters applied to the selected + grouping algorithm. """ if not feature_maps: @@ -187,7 +196,7 @@ def align_and_link( native_maps = [cls._copy_feature_map(feature_map) for feature_map in feature_maps] cls._align_feature_maps(native_maps, alignment_method, alignment_params) - consensus_map = cls._link_feature_maps(native_maps) + consensus_map = cls._link_feature_maps(native_maps, grouping_method, grouping_params) return cls(consensus_map) # ==================== pandas integration ==================== @@ -388,11 +397,37 @@ def _create_alignment_algorithm(method: str): "Unsupported alignment_method. Use 'pose_clustering', 'identification', or 'identity'." ) + @staticmethod + def _create_grouping_algorithm(method: str): + """Create the appropriate feature grouping algorithm.""" + normalized = method.lower() + if normalized in {"qt", "qtcluster"}: + return oms.FeatureGroupingAlgorithmQT() + if normalized in {"kd", "tree"}: + return oms.FeatureGroupingAlgorithmKD() + if normalized == "labeled": + return oms.FeatureGroupingAlgorithmLabeled() + if normalized == "unlabeled": + return oms.FeatureGroupingAlgorithmUnlabeled() + raise ValueError( + "Unsupported grouping_method. Use 'qt', 'kd', 'labeled', or 'unlabeled'." + ) + @staticmethod def _link_feature_maps( feature_maps: Sequence[oms.FeatureMap], + grouping_method: str = "qt", + grouping_params: Optional[Dict[str, Union[int, float, str]]] = None, ) -> oms.ConsensusMap: - grouping = oms.FeatureGroupingAlgorithmQT() + grouping = Py_ConsensusMap._create_grouping_algorithm(grouping_method) + + # Apply parameters if provided + if grouping_params: + params = grouping.getDefaults() + for key, value in grouping_params.items(): + params.setValue(str(key), value) + grouping.setParameters(params) + consensus_map = oms.ConsensusMap() grouping.group(feature_maps, consensus_map) diff --git a/openms_python/py_msexperiment.py b/openms_python/py_msexperiment.py index 030d2a0..be88602 100644 --- a/openms_python/py_msexperiment.py +++ b/openms_python/py_msexperiment.py @@ -15,6 +15,7 @@ PEAK_PICKER_REGISTRY: Dict[str, Any] = { "hires": oms.PeakPickerHiRes, "cwt": getattr(oms, "PeakPickerCWT", oms.PeakPickerHiRes), + "iterative": oms.PeakPickerIterative, } _FeatureMapLike = Union[Py_FeatureMap, oms.FeatureMap] diff --git a/openms_python/workflows.py b/openms_python/workflows.py index 4427928..94d9f29 100644 --- a/openms_python/workflows.py +++ b/openms_python/workflows.py @@ -193,11 +193,43 @@ def align_feature_maps( def link_features( feature_maps: Sequence[_FeatureMapLike], *, + grouping_method: str = "qt", params: Optional[Dict[str, Union[int, float, str]]] = None, ) -> Py_ConsensusMap: - """Group features across runs into a consensus map.""" - - grouping = oms.FeatureGroupingAlgorithmQT() + """Group features across runs into a consensus map. + + Parameters + ---------- + feature_maps: + Sequence of feature maps to link. + grouping_method: + Name of the OpenMS feature grouping algorithm to use. Supported values + are ``"qt"`` (QT clustering, default), ``"kd"`` (KD-tree based), + ``"labeled"`` (for labeled data), and ``"unlabeled"`` (for unlabeled data). + params: + Optional dictionary of parameters applied to the selected grouping algorithm. + + Returns + ------- + Py_ConsensusMap + The linked consensus map. + """ + + # Create grouping algorithm + normalized = grouping_method.lower() + if normalized in {"qt", "qtcluster"}: + grouping = oms.FeatureGroupingAlgorithmQT() + elif normalized in {"kd", "tree"}: + grouping = oms.FeatureGroupingAlgorithmKD() + elif normalized == "labeled": + grouping = oms.FeatureGroupingAlgorithmLabeled() + elif normalized == "unlabeled": + grouping = oms.FeatureGroupingAlgorithmUnlabeled() + else: + raise ValueError( + "Unsupported grouping_method. Use 'qt', 'kd', 'labeled', or 'unlabeled'." + ) + param_obj = grouping.getDefaults() if params: for key, value in params.items(): diff --git a/tests/test_consensus_map.py b/tests/test_consensus_map.py index a16d41c..ef664d2 100644 --- a/tests/test_consensus_map.py +++ b/tests/test_consensus_map.py @@ -33,3 +33,45 @@ def test_align_and_link_invalid_method_raises(): fmap = Py_FeatureMap(_simple_feature_map(10.0)) with pytest.raises(ValueError): Py_ConsensusMap.align_and_link([fmap], alignment_method="unknown") + + +def test_align_and_link_with_kd_grouping(): + """Test that KD-tree grouping method is supported.""" + fmap_a = Py_FeatureMap(_simple_feature_map(10.0)) + fmap_b = Py_FeatureMap(_simple_feature_map(10.0)) + + consensus = Py_ConsensusMap.align_and_link( + [fmap_a, fmap_b], + alignment_method="identity", + grouping_method="kd", + ) + + assert isinstance(consensus, Py_ConsensusMap) + assert len(consensus) == 1 + + +def test_align_and_link_invalid_grouping_raises(): + """Test that invalid grouping method raises error.""" + fmap = Py_FeatureMap(_simple_feature_map(10.0)) + with pytest.raises(ValueError, match="Unsupported grouping_method"): + Py_ConsensusMap.align_and_link( + [fmap], + alignment_method="identity", + grouping_method="invalid" + ) + + +def test_align_and_link_with_grouping_params(): + """Test that grouping parameters can be passed.""" + fmap_a = Py_FeatureMap(_simple_feature_map(10.0)) + fmap_b = Py_FeatureMap(_simple_feature_map(10.0)) + + consensus = Py_ConsensusMap.align_and_link( + [fmap_a, fmap_b], + alignment_method="identity", + grouping_method="qt", + grouping_params={"distance_RT:max_difference": 100.0}, + ) + + assert isinstance(consensus, Py_ConsensusMap) + assert len(consensus) == 1 diff --git a/tests/test_py_msexperiment.py b/tests/test_py_msexperiment.py index 7488759..24e00cf 100644 --- a/tests/test_py_msexperiment.py +++ b/tests/test_py_msexperiment.py @@ -323,3 +323,15 @@ def pick(self, source, dest): assert len(processed) == len(exp) assert all(np.allclose(spec.mz, 42.0) for spec in exp) + +def test_peak_picking_iterative_method(): + """Test that iterative peak picking method is available.""" + exp = build_experiment() + # Just verify that iterative method can be selected + try: + picked = exp.pick_peaks(method="iterative", ms_levels=1) + assert isinstance(picked, Py_MSExperiment) + except Exception as e: + # It may fail on minimal data, but should not fail on unknown method + assert "Unknown peak picking method" not in str(e) + diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 5bb694c..1f12351 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -156,3 +156,50 @@ def test_link_features_and_export_quant_table(): assert df.shape[0] >= 1 # Expect one column per input map assert {col for col in df.columns if col.startswith("map_")} + + +def test_link_features_with_kd_grouping(): + """Test that KD-tree grouping method works in link_features.""" + fmap_a = oms.FeatureMap() + feat_a = oms.Feature() + feat_a.setRT(10.0) + feat_a.setMZ(500.0) + feat_a.setIntensity(100.0) + fmap_a.push_back(feat_a) + + fmap_b = oms.FeatureMap() + feat_b = oms.Feature() + feat_b.setRT(10.0) + feat_b.setMZ(500.0) + feat_b.setIntensity(110.0) + fmap_b.push_back(feat_b) + + consensus = link_features( + [Py_FeatureMap(fmap_a), Py_FeatureMap(fmap_b)], + grouping_method="kd" + ) + assert len(consensus) == 1 + + +def test_link_features_with_unlabeled_grouping(): + """Test that unlabeled grouping method works in link_features.""" + fmap_a = oms.FeatureMap() + feat_a = oms.Feature() + feat_a.setRT(10.0) + feat_a.setMZ(500.0) + feat_a.setIntensity(100.0) + fmap_a.push_back(feat_a) + + fmap_b = oms.FeatureMap() + feat_b = oms.Feature() + feat_b.setRT(10.0) + feat_b.setMZ(500.0) + feat_b.setIntensity(110.0) + fmap_b.push_back(feat_b) + + consensus = link_features( + [Py_FeatureMap(fmap_a), Py_FeatureMap(fmap_b)], + grouping_method="unlabeled" + ) + assert len(consensus) == 1 +