"""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 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