diff --git a/openms_python/__init__.py b/openms_python/__init__.py index 28f5a15..1573760 100644 --- a/openms_python/__init__.py +++ b/openms_python/__init__.py @@ -30,6 +30,7 @@ from .py_consensusmap import Py_ConsensusMap from .py_experimentaldesign import Py_ExperimentalDesign from .py_aasequence import Py_AASequence +from .py_residue import Py_Residue from .py_identifications import ( ProteinIdentifications, PeptideIdentifications, @@ -109,6 +110,7 @@ def get_example(name: str, *, load: bool = False, target_dir: Union[str, Path, N "Py_ConsensusMap", "Py_ExperimentalDesign", "Py_AASequence", + "Py_Residue", "ProteinIdentifications", "PeptideIdentifications", "Identifications", diff --git a/openms_python/py_aasequence.py b/openms_python/py_aasequence.py index a6cc4b2..c3a0ac0 100644 --- a/openms_python/py_aasequence.py +++ b/openms_python/py_aasequence.py @@ -5,6 +5,7 @@ from typing import Optional, Literal import pyopenms as oms import warnings +from .py_residue import Py_Residue class Py_AASequence: @@ -64,10 +65,10 @@ def from_native(cls, native_sequence: oms.AASequence) -> Py_AASequence: Creates Py_AASequence from native pyOpenMS AASequence. Args: - native_sequence (oms.AASequence): + native_sequence (oms.AASequence): Native pyOpenMS AASequence object. Returns: - Py_AASequence: New wrapped opject + Py_AASequence: New wrapped object. """ return cls(native_sequence) @@ -242,20 +243,20 @@ def __getitem__(self, index): if step != 1: raise ValueError("Step slicing is not supported for amino acid sequences") return Py_AASequence.from_native(self._sequence.getSubsequence(start, stop - start)) - else: + else: # isinstance(index, int) # Handle negative indices if index < 0: index = len(self) + index if index >= len(self): raise IndexError(f"Index {index} out of range for sequence of length {len(self)}") - residue = self._sequence.getSubsequence(index, 1) - return Py_AASequence.from_native(residue) + residue = self._sequence.getResidue(index) + return Py_Residue.from_native(residue) def __iter__(self): """Iterate over residues.""" for i in range(len(self)): yield self[i] - def __add__(self, other: Py_AASequence | str) -> Py_AASequence: + def __add__(self, other: Py_AASequence | str | Py_Residue) -> Py_AASequence: """ Concatenate sequences. @@ -279,6 +280,8 @@ def __add__(self, other: Py_AASequence | str) -> Py_AASequence: combined_str = self.sequence + other.sequence elif isinstance(other, str): combined_str = self.sequence + other + elif isinstance(other, Py_Residue): + combined_str = self.sequence + other.one_letter_code else: return NotImplemented return Py_AASequence.from_string(combined_str) @@ -296,6 +299,9 @@ def __radd__(self, other: str) -> Py_AASequence: if isinstance(other, str): combined_str = other + self.sequence return Py_AASequence.from_string(combined_str) + if isinstance(other, Py_Residue): + combined_str = other.one_letter_code + self.sequence + return Py_AASequence.from_string(combined_str) return NotImplemented def __mul__(self, times: int) -> Py_AASequence: diff --git a/openms_python/py_residue.py b/openms_python/py_residue.py new file mode 100644 index 0000000..50dcc8e --- /dev/null +++ b/openms_python/py_residue.py @@ -0,0 +1,403 @@ +"""Pythonic wrapper for pyOpenMS Residue class.""" + +from __future__ import annotations + +from typing import Optional, Set, List +import pyopenms as oms + + +class Py_Residue: + """ + A Pythonic, immutable wrapper around pyOpenMS Residue. + + This class provides intuitive properties and methods for working with + amino acid residues, including access to modifications, formulas, + weights, and physicochemical properties. + + Example: + >>> # Get a residue from the database + >>> res = Py_Residue.from_one_letter_code("A") + >>> print(res.name) + Alanine + >>> print(res.mono_weight) + 89.047... + >>> print(res.formula) + C3H7NO2 + >>> # Check if modified + >>> print(res.is_modified) + False + """ + + def __init__(self, native_residue: Optional[oms.Residue] = None): + """ + Initialize Py_Residue wrapper. + + Args: + native_residue: pyOpenMS Residue object. If None, creates empty residue. + """ + self._residue = native_residue if native_residue is not None else oms.Residue() + + @classmethod + def from_native(cls, native_residue: oms.Residue) -> Py_Residue: + """ + Create Py_Residue from native pyOpenMS Residue. + + Args: + native_residue: Native pyOpenMS Residue object. + + Returns: + Py_Residue: New wrapped object. + """ + return cls(native_residue) + + @classmethod + def from_string(cls, code: str) -> Py_Residue: + """ + Get residue from ResidueDB by any valid identifier. + + Intelligently infers the input format and retrieves the residue accordingly. + Supports: + - One-letter codes: "A", "R", "N", etc. + - Three-letter codes: "Ala", "Arg", "Asn", etc. + - Full names: "Alanine", "Arginine", "Asparagine", etc. + + Args: + code: One-letter code, three-letter code, or full name. + + Returns: + Py_Residue: Wrapped residue from database. + + Raises: + ValueError: If the code format cannot be recognized or residue not found. + + Example: + >>> res = Py_Residue.from_string("A") + >>> res = Py_Residue.from_string("Ala") + >>> res = Py_Residue.from_string("Alanine") + """ + if not code or not isinstance(code, str): + raise ValueError(f"Invalid code: {code}") + + code = code.strip() + db = oms.ResidueDB() + + # Try direct lookup (works for all formats) + try: + residue = db.getResidue(code) + return cls(residue) + except Exception: + raise ValueError(f"Residue '{code}' not found in ResidueDB.") + + # ==================== Core Properties ==================== + + @property + def native(self) -> oms.Residue: + """Return the underlying pyOpenMS Residue.""" + return self._residue + + @property + def name(self) -> str: + """Get the full name of the residue.""" + return self._residue.getName() + + @property + def one_letter_code(self) -> str: + """Get the one-letter code.""" + return self._residue.getOneLetterCode() + + @property + def three_letter_code(self) -> str: + """Get the three-letter code.""" + return self._residue.getThreeLetterCode() + + @property + def synonyms(self) -> Set[str]: + """Get synonyms for this residue.""" + return self._residue.getSynonyms() + + # ==================== Weight and Formula ==================== + + @property + def mono_weight(self) -> float: + """ + Get monoisotopic weight. + + Args can be passed to get weight for different residue types + (Full, Internal, NTerminal, CTerminal, etc.). + """ + return self._residue.getMonoWeight() + + @property + def average_weight(self) -> float: + """Get average weight.""" + return self._residue.getAverageWeight() + + @property + def formula(self) -> str: + """ + Get empirical formula. + + Returns: + str: Formula string (e.g., 'C3H7NO2'). + """ + return self._residue.getFormula().toString() + + def get_mono_weight( + self, residue_type: oms.Residue.ResidueType = oms.Residue.ResidueType.Full + ) -> float: + """ + Get monoisotopic weight for specific residue type. + + Args: + residue_type: Type of residue (Full, Internal, NTerminal, CTerminal, + AIon, BIon, CIon, XIon, YIon, ZIon, etc.). + + Returns: + float: Monoisotopic weight. + + Example: + >>> res = Py_Residue.from_one_letter_code("A") + >>> full_weight = res.get_mono_weight(oms.Residue.ResidueType.Full) + >>> internal_weight = res.get_mono_weight(oms.Residue.ResidueType.Internal) + """ + return self._residue.getMonoWeight(residue_type) + + def get_average_weight( + self, residue_type: oms.Residue.ResidueType = oms.Residue.ResidueType.Full + ) -> float: + """ + Get average weight for specific residue type. + + Args: + residue_type: Type of residue. + + Returns: + float: Average weight. + """ + return self._residue.getAverageWeight(residue_type) + + def get_formula( + self, residue_type: oms.Residue.ResidueType = oms.Residue.ResidueType.Full + ) -> str: + """ + Get empirical formula for specific residue type. + + Args: + residue_type: Type of residue. + + Returns: + str: Formula string. + """ + return self._residue.getFormula(residue_type).toString() + + # ==================== Modifications ==================== + + @property + def is_modified(self) -> bool: + """Check if residue has a modification.""" + return self._residue.isModified() + + @property + def modification(self) -> Optional[oms.ResidueModification]: + """ + Get the modification object. + + Returns: + Optional[oms.ResidueModification]: Modification or None if not modified. + """ + return self._residue.getModification() + + @property + def modification_name(self) -> str: + """ + Get the modification name. + + Returns: + str: Modification name or empty string if not modified. + """ + return self._residue.getModificationName() + + def set_modification(self, mod_name: str) -> None: + """ + Set modification by name. + + Note: This modifies the underlying residue object. + + Args: + mod_name: Name of modification (must exist in ModificationsDB). + + Example: + >>> res = Py_Residue.from_one_letter_code("M") + >>> res.set_modification("Oxidation") + """ + self._residue.setModification(mod_name) + + def set_modification_by_diff_mass(self, diff_mono_mass: float) -> None: + """ + Set modification by mass difference. + + Searches ModificationsDB for matching modification. If not found, + creates a new user-defined modification. + + Args: + diff_mono_mass: Monoisotopic mass difference. + + Example: + >>> res = Py_Residue.from_one_letter_code("S") + >>> res.set_modification_by_diff_mass(79.966331) # Phosphorylation + """ + self._residue.setModificationByDiffMonoMass(diff_mono_mass) + + # ==================== Neutral Losses ==================== + + @property + def has_neutral_loss(self) -> bool: + """Check if residue has neutral losses.""" + return self._residue.hasNeutralLoss() + + @property + def has_n_term_neutral_losses(self) -> bool: + """Check if residue has N-terminal neutral losses.""" + return self._residue.hasNTermNeutralLosses() + + @property + def loss_formulas(self) -> List[str]: + """ + Get neutral loss formulas. + + Returns: + List[str]: List of formula strings. + """ + formulas = self._residue.getLossFormulas() + return [f.toString() for f in formulas] + + @property + def loss_names(self) -> List[str]: + """Get neutral loss names.""" + return list(self._residue.getLossNames()) + + @property + def n_term_loss_formulas(self) -> List[str]: + """ + Get N-terminal loss formulas. + + Returns: + List[str]: List of formula strings. + """ + formulas = self._residue.getNTermLossFormulas() + return [f.toString() for f in formulas] + + @property + def n_term_loss_names(self) -> List[str]: + """Get N-terminal loss names.""" + return list(self._residue.getNTermLossNames()) + + # ==================== Low Mass Ions ==================== + + @property + def low_mass_ions(self) -> List[str]: + """ + Get low mass marker ions (e.g., immonium ions). + + Returns: + List[str]: List of formula strings. + """ + ions = self._residue.getLowMassIons() + return [ion.toString() for ion in ions] + + # ==================== Physicochemical Properties ==================== + + @property + def pka(self) -> float: + """Get pKa value.""" + return self._residue.getPka() + + @property + def pkb(self) -> float: + """Get pKb value.""" + return self._residue.getPkb() + + @property + def pkc(self) -> float: + """Get pKc value (returns -1 if not applicable).""" + return self._residue.getPkc() + + @property + def pi_value(self) -> float: + """Calculate isoelectric point from pK values.""" + return self._residue.getPiValue() + + @property + def side_chain_basicity(self) -> float: + """Get side chain basicity (gas phase).""" + return self._residue.getSideChainBasicity() + + @property + def backbone_basicity_left(self) -> float: + """Get backbone basicity in N-terminal direction.""" + return self._residue.getBackboneBasicityLeft() + + @property + def backbone_basicity_right(self) -> float: + """Get backbone basicity in C-terminal direction.""" + return self._residue.getBackboneBasicityRight() + + # ==================== Residue Sets ==================== + + @property + def residue_sets(self) -> Set[str]: + """ + Get residue sets this amino acid belongs to. + + Example sets: 'Natural20', 'Natural19WithoutL', etc. + + Returns: + Set[str]: Set of residue set names. + """ + return self._residue.getResidueSets() + + def is_in_residue_set(self, residue_set: str) -> bool: + """ + Check if residue is in a specific set. + + Args: + residue_set: Name of the residue set (e.g., 'Natural20'). + + Returns: + bool: True if residue is in the set. + + Example: + >>> ala = Py_Residue.from_one_letter_code("A") + >>> print(ala.is_in_residue_set("Natural20")) + True + """ + return self._residue.isInResidueSet(residue_set) + + # ==================== Magic Methods ==================== + + def __str__(self) -> str: + """String representation using one_letter_code.""" + return self.one_letter_code + + def __repr__(self) -> str: + """Developer-friendly representation.""" + if self.is_modified: + return f"Py_Residue('{self.one_letter_code}', modified='{self.modification_name}')" + return f"Py_Residue('{self.one_letter_code}', name='{self.name}')" + + def __eq__(self, other: object) -> bool: + """Check equality based on residue properties.""" + if isinstance(other, Py_Residue): + return self._residue == other._residue + elif isinstance(other, str): + # Allow comparison with one-letter code + return self.one_letter_code == other + return False + + def __ne__(self, other: object) -> bool: + """Check inequality.""" + return not self.__eq__(other) + + def __hash__(self) -> int: + """Make residues hashable.""" + return hash((self.one_letter_code, self.modification_name)) \ No newline at end of file diff --git a/tests/test_py_aasequence.py b/tests/test_py_aasequence.py index 93ee370..b375c47 100644 --- a/tests/test_py_aasequence.py +++ b/tests/test_py_aasequence.py @@ -5,7 +5,7 @@ import pytest import pyopenms as oms -from openms_python.py_aasequence import Py_AASequence +from openms_python import Py_AASequence, Py_Residue def test_py_aasequence_from_string(): @@ -118,7 +118,7 @@ def test_py_aasequence_iteration(): seq = Py_AASequence.from_string("PEPTIDE") residues = list(seq) - assert [res.sequence for res in residues] == ["P", "E", "P", "T", "I", "D", "E"] + assert [res.one_letter_code for res in residues] == ["P", "E", "P", "T", "I", "D", "E"] assert len(residues) == 7 @@ -126,9 +126,9 @@ def test_py_aasequence_indexing(): """Test indexing into sequence.""" seq = Py_AASequence.from_string("PEPTIDE") - assert seq[0].sequence == "P" - assert seq[1].sequence == "E" - assert seq[6].sequence == "E" + assert seq[0].one_letter_code == "P" + assert seq[1].one_letter_code == "E" + assert seq[6].one_letter_code == "E" # Test out of bounds with pytest.raises(IndexError): @@ -284,8 +284,8 @@ def test_py_aasequence_to_string(): def test_slicing(): aa_seq = Py_AASequence.from_string('PEPTIDEM(Oxidation)R') - assert aa_seq[0].sequence == 'P' - assert aa_seq[-1].sequence == 'R' + assert aa_seq[0].one_letter_code == 'P' + assert aa_seq[-1].one_letter_code == 'R' assert aa_seq[1:4].sequence == 'EPT' assert aa_seq[-2:].sequence == 'M(Oxidation)R' @@ -295,3 +295,33 @@ def test_count(): assert aa_seq.count('P') == 2 assert aa_seq.count('K') == 0 +def test_py_aasequence_addition(): + """Test sequence concatenation with + operator.""" + seq1 = Py_AASequence.from_string("PEP") + seq2 = Py_AASequence.from_string("TIDE") + + # Test Py_AASequence + Py_AASequence + combined = seq1 + seq2 + assert combined.sequence == "PEPTIDE" + assert len(combined) == 7 + + # Test Py_AASequence + str + combined2 = seq1 + "TIDE" + assert combined2.sequence == "PEPTIDE" + + # Test str + Py_AASequence (radd) + combined3 = "PEP" + seq2 + assert combined3.sequence == "PEPTIDE" + + # Original sequences should be unchanged + assert seq1.sequence == "PEP" + assert seq2.sequence == "TIDE" + + # Test adding with Py_Residue + residue = Py_Residue.from_string("K") + combined4 = seq1 + residue + assert combined4.sequence == "PEPK" + + # Test chaining + combined5 = seq1 + seq2 + residue + assert combined5.sequence == "PEPTIDEK" diff --git a/tests/test_py_residue.py b/tests/test_py_residue.py new file mode 100644 index 0000000..d741812 --- /dev/null +++ b/tests/test_py_residue.py @@ -0,0 +1,427 @@ +"""Tests for Py_Residue wrapper.""" + +from __future__ import annotations + +import pytest +import pyopenms as oms + +from openms_python.py_residue import Py_Residue + + +def test_py_residue_from_string(): + + """Test creating residue from one-letter code.""" + ala = Py_Residue.from_string("A") + assert ala.one_letter_code == "A" + assert ala.three_letter_code == "Ala" + assert ala.name == "Alanine" + + """Test creating residue from three-letter code.""" + ala = Py_Residue.from_string("Ala") + assert ala.one_letter_code == "A" + assert ala.three_letter_code == "Ala" + assert ala.name == "Alanine" + + """Test creating residue from full name.""" + ala = Py_Residue.from_string("Alanine") + assert ala.one_letter_code == "A" + assert ala.three_letter_code == "Ala" + assert ala.name == "Alanine" + + +def test_py_residue_basic_properties(): + """Test basic properties of residues.""" + # Test alanine + ala = Py_Residue.from_string("A") + + assert ala.name == "Alanine" + assert ala.one_letter_code == "A" + assert ala.three_letter_code == "Ala" + + # Test synonyms (if any) + assert isinstance(ala.synonyms, set) + + +def test_py_residue_weight_and_formula(): + """Test weight and formula properties.""" + ala = Py_Residue.from_string("A") + + # Test weights + assert ala.mono_weight > 0 + assert ala.average_weight > 0 + assert ala.mono_weight != ala.average_weight + + # Test formula + assert isinstance(ala.formula, str) + assert "C" in ala.formula + assert "H" in ala.formula + assert "N" in ala.formula + assert "O" in ala.formula + + +def test_py_residue_different_amino_acids(): + """Test different amino acids have different properties.""" + ala = Py_Residue.from_string("A") + arg = Py_Residue.from_string("R") + + assert ala.name != arg.name + assert ala.mono_weight != arg.mono_weight + assert ala.formula != arg.formula + + +def test_py_residue_modification_status(): + """Test modification status for unmodified residue.""" + ala = Py_Residue.from_string("A") + + assert not ala.is_modified + assert ala.modification is None + assert ala.modification_name == "" + + +def test_py_residue_set_modification(): + """Test setting modification by name.""" + met = Py_Residue.from_string("M") + + # Before modification + assert not met.is_modified + + # Set oxidation + met.set_modification("Oxidation") + + # After modification + assert met.is_modified + assert met.modification is not None + assert "Oxidation" in met.modification_name + + +def test_py_residue_set_modification_by_diff_mass(): + """Test setting modification by mass difference.""" + ser = Py_Residue.from_string("S") + + # Set phosphorylation by mass difference + phospho_mass = 79.966331 + ser.set_modification_by_diff_mass(phospho_mass) + + assert ser.is_modified + assert ser.modification is not None + + +def test_py_residue_neutral_losses(): + """Test neutral loss properties.""" + # Serine has neutral loss of water + ser = Py_Residue.from_string("S") + + # Check if has neutral loss (property may vary by residue) + has_loss = ser.has_neutral_loss + assert isinstance(has_loss, bool) + + # Get loss formulas and names + loss_formulas = ser.loss_formulas + loss_names = ser.loss_names + + assert isinstance(loss_formulas, list) + assert isinstance(loss_names, list) + + +def test_py_residue_n_term_neutral_losses(): + """Test N-terminal neutral loss properties.""" + ala = Py_Residue.from_string("A") + + has_n_term_loss = ala.has_n_term_neutral_losses + assert isinstance(has_n_term_loss, bool) + + n_term_formulas = ala.n_term_loss_formulas + n_term_names = ala.n_term_loss_names + + assert isinstance(n_term_formulas, list) + assert isinstance(n_term_names, list) + + +def test_py_residue_low_mass_ions(): + """Test low mass ions (immonium ions).""" + leu = Py_Residue.from_string("L") + + low_mass = leu.low_mass_ions + assert isinstance(low_mass, list) + # Each element should be a formula string + for ion in low_mass: + assert isinstance(ion, str) + + +def test_py_residue_pk_values(): + """Test pK values.""" + # Lysine has a side chain pKa + lys = Py_Residue.from_string("K") + + pka = lys.pka + pkb = lys.pkb + pkc = lys.pkc + + assert isinstance(pka, float) + assert isinstance(pkb, float) + assert isinstance(pkc, float) + + # Test isoelectric point calculation + pi = lys.pi_value + assert isinstance(pi, float) + assert pi > 0 + + +def test_py_residue_basicity_values(): + """Test gas phase basicity values.""" + ala = Py_Residue.from_string("A") + + sc_basicity = ala.side_chain_basicity + bb_left = ala.backbone_basicity_left + bb_right = ala.backbone_basicity_right + + assert isinstance(sc_basicity, float) + assert isinstance(bb_left, float) + assert isinstance(bb_right, float) + + +def test_py_residue_residue_sets(): + """Test residue sets.""" + ala = Py_Residue.from_string("A") + + # Get all sets this residue belongs to + sets = ala.residue_sets + assert isinstance(sets, set) + + # Check if in Natural20 (standard amino acids) + is_natural = ala.is_in_residue_set("Natural20") + assert isinstance(is_natural, bool) + + +def test_py_residue_get_weight_by_type(): + """Test getting weights for different residue types.""" + ala = Py_Residue.from_string("A") + + # Get weights for different types + full_weight = ala.get_mono_weight(oms.Residue.ResidueType.Full) + internal_weight = ala.get_mono_weight(oms.Residue.ResidueType.Internal) + b_ion_weight = ala.get_mono_weight(oms.Residue.ResidueType.BIon) + y_ion_weight = ala.get_mono_weight(oms.Residue.ResidueType.YIon) + + assert full_weight > 0 + assert internal_weight > 0 + assert b_ion_weight > 0 + assert y_ion_weight > 0 + + # Full weight should be different from internal + assert full_weight != internal_weight + + +def test_py_residue_get_formula_by_type(): + """Test getting formulas for different residue types.""" + ala = Py_Residue.from_string("A") + + # Get formulas for different types + full_formula = ala.get_formula(oms.Residue.ResidueType.Full) + internal_formula = ala.get_formula(oms.Residue.ResidueType.Internal) + b_ion_formula = ala.get_formula(oms.Residue.ResidueType.BIon) + + assert isinstance(full_formula, str) + assert isinstance(internal_formula, str) + assert isinstance(b_ion_formula, str) + + # Formulas should be different + assert full_formula != internal_formula + + +def test_py_residue_get_average_weight_by_type(): + """Test getting average weights for different residue types.""" + ala = Py_Residue.from_string("A") + + full_avg = ala.get_average_weight(oms.Residue.ResidueType.Full) + internal_avg = ala.get_average_weight(oms.Residue.ResidueType.Internal) + + assert full_avg > 0 + assert internal_avg > 0 + assert full_avg != internal_avg + + +def test_py_residue_string_representation(): + """Test string representations.""" + ala = Py_Residue.from_string("A") + + # Test __str__ + str_repr = str(ala) + assert isinstance(str_repr, str) + + # Test __repr__ + repr_str = repr(ala) + assert "Py_Residue" in repr_str + assert "A" in repr_str + + +def test_py_residue_string_representation_modified(): + """Test string representation of modified residue.""" + met = Py_Residue.from_string("M") + met.set_modification("Oxidation") + + repr_str = repr(met) + assert "Py_Residue" in repr_str + assert "M" in repr_str + assert "modified" in repr_str.lower() + + +def test_py_residue_equality(): + """Test equality comparisons.""" + ala1 = Py_Residue.from_string("A") + ala2 = Py_Residue.from_string("A") + arg = Py_Residue.from_string("R") + + # Same residue should be equal + assert ala1 == ala2 + + # Different residues should not be equal + assert ala1 != arg + + # Test string comparison with one-letter code + assert ala1 == "A" + assert ala1 != "R" + + # Test inequality operator + assert not (ala1 != ala2) + assert ala1 != arg + + +def test_py_residue_hashable(): + """Test that residues are hashable.""" + ala = Py_Residue.from_string("A") + arg = Py_Residue.from_string("R") + + # Should be able to create a set + residue_set = {ala, arg} + assert len(residue_set) == 2 + + # Should be able to use as dict key + residue_dict = {ala: "alanine", arg: "arginine"} + assert residue_dict[ala] == "alanine" + assert residue_dict[arg] == "arginine" + + +def test_py_residue_native_access(): + """Test access to native pyOpenMS object.""" + ala = Py_Residue.from_string("A") + native = ala.native + + assert isinstance(native, oms.Residue) + assert native.getOneLetterCode() == "A" + + +def test_py_residue_from_native(): + """Test creating Py_Residue from native object.""" + # Get native residue + db = oms.ResidueDB() + native_ala = db.getResidue("A") + + # Wrap it + ala = Py_Residue.from_native(native_ala) + + assert ala.one_letter_code == "A" + assert ala.native is native_ala + +def test_py_residue_all_standard_amino_acids(): + """Test that all standard amino acids can be loaded.""" + standard_aa = "ACDEFGHIKLMNPQRSTVWY" + + for aa in standard_aa: + residue = Py_Residue.from_string(aa) + assert residue.one_letter_code == aa + assert residue.mono_weight > 0 + assert len(residue.formula) > 0 + + +def test_py_residue_modified_equality(): + """Test equality of modified residues.""" + met1 = Py_Residue.from_string("M") + met2 = Py_Residue.from_string("M") + + # Before modification, should be equal + assert met1 == met2 + + # Modify one + met1.set_modification("Oxidation") + + # After modification, should not be equal (different modification state) + assert met1 != met2 + + # Both modified with same modification should be equal + met2.set_modification("Oxidation") + # Note: This might depend on implementation details + + +def test_py_residue_weight_formula_consistency(): + """Test that weights and formulas are consistent.""" + ala = Py_Residue.from_string("A") + + # For a given residue type, weight and formula should be consistent + mono_weight = ala.get_mono_weight(oms.Residue.ResidueType.Full) + formula = ala.get_formula(oms.Residue.ResidueType.Full) + + # Weight should be positive + assert mono_weight > 0 + # Formula should contain elements + assert "C" in formula or "H" in formula + + +def test_py_residue_empty_residue(): + """Test creating empty residue.""" + empty = Py_Residue() + + # Should have empty properties + assert empty.name == "unknown" + assert empty.one_letter_code == "" + + +def test_py_residue_special_amino_acids(): + """Test special amino acids like selenocysteine.""" + # Note: Availability depends on ResidueDB configuration + # This test might need adjustment based on your OpenMS version + try: + # Try to get selenocysteine (U) + sec = Py_Residue.from_string("U") + assert sec.one_letter_code == "U" + except: + # If not available, that's okay for standard configurations + pass + + +def test_py_residue_modification_persists(): + """Test that modifications persist on the residue object.""" + met = Py_Residue.from_string("M") + + # Get weight before modification + weight_before = met.mono_weight + + # Add modification + met.set_modification("Oxidation") + + # Weight should change + weight_after = met.mono_weight + assert weight_after != weight_before + + # Modification should still be there + assert met.is_modified + assert met.modification is not None + + +def test_py_residue_different_residue_types_give_different_weights(): + """Test that different residue types produce different weights.""" + ala = Py_Residue.from_string("A") + + # Get weights for multiple types + weights = { + 'Full': ala.get_mono_weight(oms.Residue.ResidueType.Full), + 'Internal': ala.get_mono_weight(oms.Residue.ResidueType.Internal), + 'BIon': ala.get_mono_weight(oms.Residue.ResidueType.BIon), + 'YIon': ala.get_mono_weight(oms.Residue.ResidueType.YIon), + } + + # Convert to set to check uniqueness + unique_weights = set(weights.values()) + + # Should have multiple different weight values + assert len(unique_weights) > 1