Source code for paftacular.comps.ions

"""Ion type definitions for mzPAF annotations"""

import re
from collections import Counter
from dataclasses import KW_ONLY, dataclass
from functools import cache, cached_property
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import peptacular as pt
else:
    try:
        import peptacular as pt
    except ImportError:
        pt = None
from tacular import AA_LOOKUP, ELEMENT_LOOKUP, FRAGMENT_ION_LOOKUP, AminoAcid, ElementInfo, RefMolInfo

from ..constants import _ADDUCT_BODY, IMMONIUM_AMINO_ACIDS, IonSeries
from ..errors import PaftacularError, PafUnsupportedCalculationError, reraise_as_paftacular
from ..util import to_enum, validate_integer
from .base import CompositionProvider, MassProvider, Serializable
from .util import composition_to_formula_string, composition_to_proforma_formula_string, formula_to_composition, lookup_reference


def immonium_amino_acid(value: object) -> AminoAcid:
    """Convert ``value`` to a standard :class:`tacular.AminoAcid`, as a PaftacularError otherwise."""
    if not isinstance(value, str) or value not in IMMONIUM_AMINO_ACIDS:
        choices = ", ".join(sorted(IMMONIUM_AMINO_ACIDS))
        raise PaftacularError(f"Invalid immonium amino acid {value!r}. Expected one of: {choices}")
    return AminoAcid(value)


def _require_peptacular() -> None:
    if pt is None:
        raise ImportError("peptacular is required for this feature. Install it with: pip install paftacular[peptacular]")


def _validate_sequence(sequence: object) -> None:
    if sequence is not None and (not isinstance(sequence, str) or not sequence):
        raise PaftacularError("Sequence must be a nonempty string or None")


def _modification(text: str) -> "pt.ModificationTags":
    """Parse an immonium modification with peptacular, as a PaftacularError on failure."""
    _require_peptacular()
    try:
        return pt.ModificationTags.from_string(text)
    except ValueError as error:
        raise PaftacularError(f"Invalid immonium modification {text!r}: {error}") from error


@cache
def _ion_offset_mass(key: str, monoisotopic: bool) -> float:
    """The mass of a tacular ion-type offset, summed from its composition.

    tacular stores the offset mass rounded to 6 decimals (``i`` is -27.994915). The exact element
    masses keep paftacular within 1e-9 Da of peptacular.
    """
    return sum(element.get_mass(monoisotopic=monoisotopic) * count for element, count in FRAGMENT_ION_LOOKUP[key].composition.items())


# tacular keys whose composition differs from the mzPAF 1.0.1 section 4.4.3 table.
# mzPAF z is the z-dot radical (sum + H2O - NH2), which tacular calls "z.".
_SERIES_LOOKUP_KEY = {IonSeries.Z: "z."}

# Section 4.4.3 side-chain ions. The offset is relative to the other n-1 residues
# (the first n-1 for d, the last n-1 for v and w) and assumes a residue whose beta
# carbon keeps one hydrogen. Residue-specific values need the sequence.
SIDE_CHAIN_SERIES_FORMULA = {IonSeries.D: "C2H4N", IonSeries.V: "C2H3NO2", IonSeries.W: "C3H4O2"}
SIDE_CHAIN_SERIES = frozenset({IonSeries.D, IonSeries.DA, IonSeries.DB, IonSeries.V, IonSeries.W, IonSeries.WA, IonSeries.WB})


[docs] @dataclass(frozen=True, slots=True) class PeptideIon(Serializable, CompositionProvider, MassProvider): """Represents a primary peptide fragment ion""" series: IonSeries position: int _: KW_ONLY sequence: str | None = None # ProForma sequence def __post_init__(self): validate_integer(self.position, "Position", minimum=1) if type(self.series) is not IonSeries: object.__setattr__(self, "series", to_enum(IonSeries, self.series, "ion series")) _validate_sequence(self.sequence)
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: if self.series in SIDE_CHAIN_SERIES: return sum(element.get_mass(monoisotopic=monoisotopic) * count for element, count in self.composition.items()) return _ion_offset_mass(_SERIES_LOOKUP_KEY.get(self.series, self.series), monoisotopic)
@property def formula(self) -> str: if self.series in SIDE_CHAIN_SERIES: return composition_to_formula_string(self.composition) formula = FRAGMENT_ION_LOOKUP[_SERIES_LOOKUP_KEY.get(self.series, self.series)].formula if formula is None: raise PaftacularError(f"Formula not available for ion series: {self.series}") return formula @property def composition(self) -> Counter[ElementInfo]: if self.series in SIDE_CHAIN_SERIES: formula = SIDE_CHAIN_SERIES_FORMULA.get(self.series) if formula is None: raise PaftacularError(f"The {self.series} ion depends on the residue, so it needs a sequence") return formula_to_composition(formula) comp: Counter[ElementInfo] = FRAGMENT_ION_LOOKUP[_SERIES_LOOKUP_KEY.get(self.series, self.series)].composition if comp is None: raise PaftacularError(f"Composition not available for ion series: {self.series}") return Counter(comp) # tacular caches this Counter, so hand out a copy
[docs] def serialize(self, *, include_sequence: bool = True) -> str: result = f"{self.series}{self.position}" if include_sequence and self.sequence: result += f"{{{self.sequence}}}" return result
[docs] @staticmethod def parse(s: str) -> "PeptideIon": """Parse peptide ion string like 'b5', 'y10{PEPTIDE}'""" from ..annotation import PafAnnotation from ..parser import parse try: annotation = parse(s) except PaftacularError as error: raise PaftacularError(f"Invalid peptide ion: {s!r}") from error if not isinstance(annotation.ion_type, PeptideIon) or annotation != PafAnnotation(annotation.ion_type): raise PaftacularError(f"Invalid peptide ion component: {s!r}") return annotation.ion_type
[docs] @dataclass(frozen=True, slots=True) class InternalFragment(Serializable, CompositionProvider, MassProvider): """Represents an internal fragment ion with optional backbone cleavage specification""" start_position: int end_position: int _: KW_ONLY sequence: str | None = None # Optional backbone cleavage types. # The mzPAF documentation specifies these using neutral loss for some reason... nterm_ion_type: IonSeries | None = None # e.g., IonSeries.A, IonSeries.B, IonSeries.C cterm_ion_type: IonSeries | None = None # e.g., IonSeries.X, IonSeries.Y, IonSeries.Z def __post_init__(self): """Validate that backbone cleavage types are set together or not at all""" if (self.nterm_ion_type is None) != (self.cterm_ion_type is None): raise PaftacularError( "nterm_ion_type and cterm_ion_type must both be set or both be None, " f"got nterm_ion_type={self.nterm_ion_type!r}, cterm_ion_type={self.cterm_ion_type!r}" ) validate_integer(self.start_position, "Start position", minimum=1) validate_integer(self.end_position, "End position", minimum=self.start_position) if self.nterm_ion_type is not None and ( self.nterm_ion_type not in (IonSeries.A, IonSeries.B, IonSeries.C) or self.cterm_ion_type not in (IonSeries.X, IonSeries.Y, IonSeries.Z) ): raise PaftacularError("Internal cleavage types must be a/b/c and x/y/z") _validate_sequence(self.sequence) @property def _fragment_ion_key(self) -> str: """tacular FRAGMENT_ION_LOOKUP key for this fragment's backbone cleavage type, e.g. 'by', 'ax'""" nterm = self.nterm_ion_type if self.nterm_ion_type is not None else IonSeries.B cterm = self.cterm_ion_type if self.cterm_ion_type is not None else IonSeries.Y return f"{nterm}{cterm}"
[docs] def serialize(self, *, include_sequence: bool = True) -> str: # If using default yb cleavage, just use 'm' result = f"m{self.start_position}:{self.end_position}" if include_sequence and self.sequence: result += f"{{{self.sequence}}}" return result + self.cleavage_correction
@property def cleavage_correction(self) -> str: """Encode the actual backbone composition as mzPAF gains and losses.""" positive = Counter({element: count for element, count in self.composition.items() if count > 0}) negative = Counter({element: -count for element, count in self.composition.items() if count < 0}) gain = "+" + composition_to_formula_string(positive) if positive else "" loss = "-" + composition_to_formula_string(negative) if negative else "" return gain + loss
[docs] @staticmethod def parse(s: str) -> "InternalFragment": """Parse internal fragment string like 'm5:10', 'm5:10{PEPTIDE}'""" from ..annotation import PafAnnotation from ..constants import InternalSeries from ..parser import parse try: annotation = parse(s) except PaftacularError as error: raise PaftacularError(f"Invalid internal fragment: {s!r}") from error ion = annotation.ion_type if not isinstance(ion, InternalFragment) or annotation != PafAnnotation(ion, neutral_losses=annotation.neutral_losses): raise PaftacularError(f"Invalid internal fragment component: {s!r}") if not annotation.neutral_losses: return ion correction: Counter[ElementInfo] = Counter() for loss in annotation.neutral_losses: correction.update(loss.composition) for series in InternalSeries: candidate = InternalFragment( ion.start_position, ion.end_position, sequence=ion.sequence, nterm_ion_type=IonSeries(series[0]), cterm_ion_type=IonSeries(series[1]) ) if candidate.composition == correction: return candidate raise PaftacularError("Neutral correction does not describe a supported internal cleavage")
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: return _ion_offset_mass(self._fragment_ion_key, monoisotopic)
@property def formula(self) -> str: formula = FRAGMENT_ION_LOOKUP[self._fragment_ion_key].formula if formula is None: raise PaftacularError("Formula not available for internal fragment") return formula @property def composition(self) -> Counter[ElementInfo]: comp: Counter[ElementInfo] = FRAGMENT_ION_LOOKUP[self._fragment_ion_key].composition if comp is None: raise PaftacularError("Composition not available for internal fragment") return Counter(comp)
[docs] @dataclass(frozen=True, slots=True) class ImmoniumIon(Serializable, CompositionProvider, MassProvider): """Represents an immonium ion""" amino_acid: AminoAcid _: KW_ONLY modification: str | None = None def __post_init__(self): if type(self.amino_acid) is not AminoAcid or self.amino_acid not in IMMONIUM_AMINO_ACIDS: object.__setattr__(self, "amino_acid", immonium_amino_acid(self.amino_acid)) if self.modification is not None and (not isinstance(self.modification, str) or not self.modification): raise PaftacularError("Modification must be a nonempty string") if self.modification is not None and re.fullmatch(_ADDUCT_BODY, self.modification): raise PaftacularError(f"Immonium modification {self.modification!r} is adduct text. Put it in PafAnnotation.adducts")
[docs] def serialize(self) -> str: result = f"I{self.amino_acid}" if self.modification: result += f"[{self.modification}]" return result
[docs] @staticmethod def parse(s: str) -> "ImmoniumIon": """Parse immonium ion string like 'IK', 'IM[Oxidation]'""" s = s.strip() match = re.fullmatch(r"I([A-Z])(?:\[([^\]]+)\])?", s) if not match: raise PaftacularError(f"Invalid immonium ion: '{s}'") aa_str, modification = match.groups() return ImmoniumIon(immonium_amino_acid(aa_str), modification=modification)
[docs] @reraise_as_paftacular def get_mass(self, *, monoisotopic: bool = True) -> float: m = 0.0 if self.modification is not None: m += _modification(self.modification).get_mass(monoisotopic=monoisotopic) aa_mass = AA_LOOKUP[self.amino_acid].get_mass(monoisotopic=monoisotopic) if aa_mass is None: raise PaftacularError(f"Mass not available for amino acid: {self.amino_acid}") m += aa_mass m += _ion_offset_mass("i", monoisotopic) return m
@property def formula(self) -> str: return composition_to_proforma_formula_string(self.composition) @property @reraise_as_paftacular def composition(self) -> Counter[ElementInfo]: # Counter's `+`/`+=` (and unary `+`) drop any element whose total is <= 0, which would # make this composition silently disagree with mass() whenever a modification removes more # of an element than the residue+immonium supply (net-negative) or exactly cancels it # (net-zero). Accumulate with `.update()` (which never filters), then strip only the # exact-zero entries at the end -- negatives are kept so comp() stays consistent with mass(). c: Counter[ElementInfo] = Counter() if self.modification is not None: mod_comp = _modification(self.modification).get_composition() if mod_comp is None: raise PaftacularError(f"Composition not available for modification: {self.modification}") c.update(mod_comp) aa_comp = AA_LOOKUP[self.amino_acid].composition if aa_comp is None: raise PaftacularError(f"Composition not available for amino acid: {self.amino_acid}") c.update(aa_comp) c.update(FRAGMENT_ION_LOOKUP["i"].composition) return Counter({el: n for el, n in c.items() if n != 0})
[docs] @dataclass(frozen=True, slots=True) class ReferenceIon(Serializable, CompositionProvider, MassProvider): """Represents a reference ion""" name: str def __post_init__(self): if not isinstance(self.name, str) or not self.name: raise PaftacularError("Reference name must be a nonempty string") @property def reference(self) -> RefMolInfo: return lookup_reference(self.name)
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: return self.reference.get_mass(monoisotopic=monoisotopic)
@property def formula(self) -> str | None: return self.reference.formula @property def composition(self) -> Counter[ElementInfo]: return Counter(self.reference.composition)
[docs] def serialize(self) -> str: return f"r[{self.name}]"
[docs] @staticmethod def parse(s: str) -> "ReferenceIon": """Parse reference ion string like 'r[Phospho]'""" s = s.strip() match = re.fullmatch(r"r\[([^\]]+)\]", s) if not match: raise PaftacularError(f"Invalid reference ion: '{s}'") return ReferenceIon(name=match.group(1))
[docs] @dataclass(frozen=True, slots=True) class NamedCompound(Serializable, CompositionProvider, MassProvider): """ Represents a named compound. Example: 0@_{Urocanic Acid} """ name: str def __post_init__(self): if not isinstance(self.name, str) or not self.name: raise PaftacularError("Compound name must be a nonempty string")
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: raise PafUnsupportedCalculationError(f"{self.serialize()} has no mass: a named compound has no defined composition")
@property def composition(self) -> Counter[ElementInfo]: raise PafUnsupportedCalculationError(f"{self.serialize()} has no composition: a named compound has no defined composition")
[docs] def serialize(self) -> str: return f"_{{{self.name}}}"
[docs] @staticmethod def parse(s: str) -> "NamedCompound": """Parse named compound string like '_{Urocanic Acid}'""" s = s.strip() match = re.fullmatch(r"_\{([^\}]+)\}", s) if not match: raise PaftacularError(f"Invalid named compound: '{s}'") return NamedCompound(name=match.group(1))
[docs] @dataclass(frozen=True, slots=True) class ChemicalFormula(Serializable, CompositionProvider, MassProvider): """ Represents a chemical formula Example: f{C13H9}/-0.55ppm f{C12H9N}/0.06ppm f{C13H9N}/-2.01ppm f{C13H10N}/-0.11ppm f{C13H11N}/-0.09ppm f{C13H12N}/0.26ppm f{C14H10N}/0.19ppm f{C14H11N}/0.45ppm f{C14H10NO}/0.03ppm """ formula: str def __post_init__(self): if not isinstance(self.formula, str) or not self.formula: raise PaftacularError("Formula must be a nonempty string") @property def proforma_formula(self) -> str: return self.formula @property def composition(self) -> Counter[ElementInfo]: return formula_to_composition(self.formula)
[docs] def serialize(self) -> str: return f"f{{{self.formula}}}"
[docs] @staticmethod def parse(s: str) -> "ChemicalFormula": """Parse chemical formula string like 'f{C13H9}'""" s = s.strip() match = re.fullmatch(r"f\{([^\}]+)\}", s) if not match: raise PaftacularError(f"Invalid chemical formula: '{s}'") return ChemicalFormula(formula=match.group(1))
[docs] @dataclass(frozen=True, slots=True) class SMILESCompound(Serializable, CompositionProvider, MassProvider): """ Represents a SMILES string Example: s{CN=C=O}[M+H]/-0.55ppm s{COc(c1)cccc1C#N}[M+H+Na]^2/1.29ppm """ smiles: str def __post_init__(self): if not isinstance(self.smiles, str) or not self.smiles: raise PaftacularError("SMILES must be a nonempty string")
[docs] def serialize(self) -> str: return f"s{{{self.smiles}}}"
[docs] @staticmethod def parse(s: str) -> "SMILESCompound": """Parse SMILES compound string like 's{CN=C=O}'""" s = s.strip() match = re.fullmatch(r"s\{([^\}]+)\}", s) if not match: raise PaftacularError(f"Invalid SMILES compound: '{s}'") return SMILESCompound(smiles=match.group(1))
[docs] @cached_property def composition(self) -> Counter[ElementInfo]: try: import pysmiles except ImportError as e: raise ImportError("pysmiles is required for SMILES parsing. Install with: pip install pysmiles") from e try: mol = pysmiles.read_smiles(self.smiles, explicit_hydrogen=True) except Exception as e: raise PaftacularError(f"Invalid SMILES string '{self.smiles}': {e}") from e elem_counts: Counter[str] = Counter() if sum(mol.nodes[node_id].get("charge", 0) for node_id in mol.nodes()) != 0: raise PaftacularError("mzPAF SMILES must describe a neutral molecule. Specify charge carriers using adducts") for node_id in mol.nodes(): elem = mol.nodes[node_id].get("element", "*") if elem == "*": raise PaftacularError(f"Unknown element '*' in SMILES '{self.smiles}'. Ensure all atoms are properly specified.") isotope = mol.nodes[node_id].get("isotope") if isotope is not None: elem = f"{isotope}{elem}" elem_counts[elem] += 1 try: return Counter({ELEMENT_LOOKUP[elem]: count for elem, count in elem_counts.items()}) except KeyError as error: raise PaftacularError(f"Unknown element in SMILES '{self.smiles}': {error}") from None
@property def proforma_formula(self) -> str: return composition_to_proforma_formula_string(self.composition) @property def formula(self) -> str: return f"+{self.proforma_formula}"
[docs] @dataclass(frozen=True, slots=True) class UnknownIon(Serializable, CompositionProvider, MassProvider): """Represents an unknown/unannotated ion""" _: KW_ONLY label: int | None = None def __post_init__(self): if self.label is not None: validate_integer(self.label, "Unknown ion label")
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: raise PafUnsupportedCalculationError(f"{self.serialize()} has no mass: the ion is unannotated")
@property def composition(self) -> Counter[ElementInfo]: raise PafUnsupportedCalculationError(f"{self.serialize()} has no composition: the ion is unannotated")
[docs] def serialize(self) -> str: if self.label is not None: return f"?{self.label}" return "?"
[docs] @staticmethod def parse(s: str) -> "UnknownIon": """Parse unknown ion string like '?' or '?5'""" s = s.strip() if s == "?": return UnknownIon(label=None) match = re.fullmatch(r"\?([0-9]+)", s) if not match: raise PaftacularError(f"Invalid unknown ion: '{s}'") return UnknownIon(label=int(match.group(1)))
[docs] @dataclass(frozen=True, slots=True) class PrecursorIon(Serializable, CompositionProvider, MassProvider): """Represents a precursor ion"""
[docs] def serialize(self) -> str: return "p"
[docs] @staticmethod def parse(s: str) -> "PrecursorIon": """Parse precursor ion string 'p'""" s = s.strip() if s != "p": raise PaftacularError(f"Invalid precursor ion: '{s}'") return PrecursorIon()
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: return _ion_offset_mass("p", monoisotopic)
@property def formula(self) -> str | None: return FRAGMENT_ION_LOOKUP["p"].formula @property def composition(self) -> Counter[ElementInfo]: return Counter(FRAGMENT_ION_LOOKUP["p"].composition)
# Type aliases for cleaner code IonType = PeptideIon | InternalFragment | ImmoniumIon | ReferenceIon | NamedCompound | ChemicalFormula | SMILESCompound | UnknownIon | PrecursorIon