Source code for paftacular.comps.modifiers

"""Modifier components for mzPAF annotations"""

import re
from collections import Counter
from dataclasses import KW_ONLY, dataclass
from typing import Literal

from tacular import ELEMENT_LOOKUP, ElementInfo, RefMolInfo

from ..constants import _ATOM_TOKEN, ADDUCT_REGEX_PATTERN, ISOTOPE_REGEX_PATTERN
from ..errors import PaftacularError, PafUnknownReferenceError
from ..util import format_number, validate_number
from .base import CompositionProvider, MassProvider, ScalableComposition, Serializable
from .util import composition_to_proforma_formula_string, formula_to_composition, lookup_reference

_ISOTOPE_ELEMENT = re.compile(r"[0-9]+[A-Z][a-z]?")
_MASS_CONTENT = re.compile(r"[0-9]+(?:\.[0-9]+)?")
_FORMULA_CONTENT = re.compile(rf"([0-9]*)({_ATOM_TOKEN}+)")
_REFERENCE_CONTENT = re.compile(r"([0-9]*)\[([^\]]+)\]")


[docs] @dataclass(frozen=True, slots=True) class MassError(Serializable): """Represents mass error with value and unit""" value: float _: KW_ONLY unit: Literal["da", "ppm"] = "da" def __post_init__(self): validate_number(self.value) if self.unit not in ("da", "ppm"): raise PaftacularError(f"Unknown mass error unit: {self.unit}")
[docs] def serialize(self) -> str: if self.unit == "ppm": return f"{format_number(self.value)}ppm" return format_number(self.value)
[docs] @staticmethod def parse(s: str) -> "MassError": """Parse mass error string like '0.55ppm' or '0.06'""" s = s.strip() text, unit = (s[:-3], "ppm") if s.endswith("ppm") else (s, "da") try: value = float(text) except ValueError: raise PaftacularError(f"Invalid mass error: {s!r}") from None return MassError(value, unit=unit)
[docs] @dataclass(frozen=True, slots=True) class IsotopeSpecification(Serializable, CompositionProvider, MassProvider): """Represents isotope information""" count: int = 0 # number of isotopes above/below monoisotope _: KW_ONLY element: str | None = None # e.g., "13C", "15N" is_average: bool = False # True for averaged isotopomers def __post_init__(self): if type(self.count) is not int or type(self.is_average) is not bool: raise PaftacularError("Isotope count must be an integer and is_average must be a boolean") if self.element is not None and (not isinstance(self.element, str) or not _ISOTOPE_ELEMENT.fullmatch(self.element)): raise PaftacularError("An isotope element requires a nucleon count and element symbol") if self.is_average and self.element is not None: raise PaftacularError("Average isotopes cannot also specify an element") @property def _prefix(self) -> str: """Get prefix for serialization""" sign = "+" if self.count > 0 else "-" count_str = "" if abs(self.count) == 1 else str(abs(self.count)) return f"{sign}{count_str}"
[docs] def serialize(self) -> str: if self.count == 0: return "" if self.is_average is True: return f"{self._prefix}iA" elif self.element is not None: return f"{self._prefix}i{self.element}" else: return f"{self._prefix}i"
[docs] @staticmethod def parse(s: str) -> "IsotopeSpecification": """Parse isotope string like '+i', '-2i13C', '+iA'""" s = s.strip() match = re.fullmatch(ISOTOPE_REGEX_PATTERN, s) if not match: raise PaftacularError(f"Invalid isotope specification: '{s}'") sign_str, count_str, element_or_avg = match.groups() sign = -1 if sign_str == "-" else 1 count = (int(count_str) if count_str else 1) * sign if element_or_avg == "A": return IsotopeSpecification(count, is_average=True) elif element_or_avg: return IsotopeSpecification(count, element=element_or_avg) else: return IsotopeSpecification(count)
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: """Calculate mass contribution of isotope specification""" if monoisotopic is False: raise PaftacularError("Cannot calculate mass shift for average isotopomer specification") if self.count == 0: return 0.0 if self.is_average: raise PaftacularError("Cannot calculate mass shift for average isotopomer specification") if self.element is None: # Generic isotope (no element specified): mzPAF section 4.6 defines this as the # difference between 13C and 12C, regardless of which atom actually carries it. c13 = ELEMENT_LOOKUP["13C"].get_mass(monoisotopic=True) c12 = ELEMENT_LOOKUP.get_monoisotopic("C").get_mass(monoisotopic=True) return (c13 - c12) * self.count comp = self.composition m = 0.0 for elem, count in comp.items(): m += elem.get_mass(monoisotopic=True) * count return m
@property def composition(self) -> Counter[ElementInfo]: # lose mono and gain isotope if self.count == 0: return Counter() if self.is_average: raise PaftacularError("Cannot calculate composition for average isotopomer specification") if self.element is None: # Generic isotope (no element specified): mzPAF section 4.6 defines this as a 13C # substitution (13C in place of 12C) regardless of which atom actually carries it, so # gain one 13C and lose one 12C per count. Mirrors the element-specified path below and # keeps composition consistent with mass() (which uses the same 13C-12C shift). comp = Counter() comp[ELEMENT_LOOKUP["13C"]] = self.count comp[ELEMENT_LOOKUP.get_monoisotopic("C")] = -self.count return comp if self.element not in ELEMENT_LOOKUP: raise PaftacularError(f"Unknown element for isotope specification: {self.element}") elem_info: ElementInfo = ELEMENT_LOOKUP[self.element] # Get monoisotopic using the base element symbol (e.g., "C" from "13C") base_symbol = elem_info.symbol mono_info: ElementInfo = ELEMENT_LOOKUP.get_monoisotopic(base_symbol) comp: Counter[ElementInfo] = Counter() comp[elem_info] += self.count comp[mono_info] -= self.count return comp
[docs] @dataclass(frozen=True, slots=True) class NeutralLoss( Serializable, ScalableComposition, MassProvider, ): """Represents a neutral loss or gain""" count: int _: KW_ONLY base_formula: str | None = None # e.g., "H2O", "NH3" base_mass: float | None = None # e.g., 17.03 for direct mass specification base_reference: str | None = None # e.g., "Phospho", "iTRAQ115" (without brackets) def __post_init__(self): if type(self.count) is not int or self.count == 0: raise PaftacularError("Neutral loss count must be a nonzero integer") if sum(value is not None for value in (self.base_formula, self.base_mass, self.base_reference)) != 1: raise PaftacularError("Exactly one of formula, mass, or reference must be set") for value in (self.base_formula, self.base_reference): if value is not None and (not isinstance(value, str) or not value): raise PaftacularError("Formula and reference must be nonempty strings") if self.base_mass is not None: validate_number(self.base_mass) @property def reference(self) -> RefMolInfo | str | None: if self.base_reference is None: return None try: return lookup_reference(self.base_reference) except PafUnknownReferenceError: return self.base_reference @property def loss_type(self) -> Literal["mass", "formula", "reference"]: if self.base_mass is not None: return "mass" elif self.base_formula is not None: return "formula" elif self.base_reference is not None: return "reference" else: raise PaftacularError("Invalid NeutralLoss state") @property def _single_composition(self) -> Counter[ElementInfo]: match self.loss_type: case "formula": if self.base_formula is None: # This shouldn't happen given __post_init__ raise RuntimeError("Invalid state: formula is None") return formula_to_composition(self.base_formula) case "reference": return lookup_reference(str(self.base_reference)).composition case "mass": raise PaftacularError(f"Cannot calculate composition for mass-based loss ({self.base_mass} Da). Use a formula or reference instead.") @property def proforma_formula(self) -> str: return composition_to_proforma_formula_string(self.composition) @property def _single_formula(self) -> str: """Get formula for a single instance of the loss (without count/sign)""" match self.loss_type: case "formula": if self.base_formula is None: raise RuntimeError("Formula is None for formula-based loss") return self.base_formula case "reference": return lookup_reference(str(self.base_reference)).formula case "mass": raise PaftacularError(f"Cannot get formula for mass-based loss: {self.base_mass}") case _: raise PaftacularError(f"Invalid loss_type: {self.loss_type}") @property def formula(self) -> str: single_formula = self._single_formula return f"{self._sign_prefix}{single_formula}" def _mass_single(self, *, monoisotopic: bool = True) -> float: match self.loss_type: case "mass": if self.base_mass is None: raise RuntimeError("Mass is None for mass-based loss") return self.base_mass case "formula": comp: Counter[ElementInfo] = self._single_composition if comp is None: raise RuntimeError("Composition is None for formula-based loss") m = 0 for elem, count in comp.items(): m += elem.get_mass(monoisotopic=monoisotopic) * count return m case "reference": return lookup_reference(str(self.base_reference)).get_mass(monoisotopic=monoisotopic)
[docs] def get_mass(self, *, monoisotopic: bool = True) -> float: return self._mass_single(monoisotopic=monoisotopic) * self.count
[docs] def serialize(self, *, loss_type: Literal["mass", "formula", "reference"] | None = None, monoisotopic: bool = True) -> str: if loss_type is None: loss_type = self.loss_type match loss_type: case "mass": mass = self.get_mass(monoisotopic=monoisotopic) return ("+" if mass >= 0 else "-") + format_number(abs(mass), minimum_places=5) case "formula": formula = self.formula return f"{formula}" case "reference": if self.base_reference is not None: ref_name = self.base_reference return f"{self._sign_prefix}[{ref_name}]" else: raise PaftacularError("Cannot serialize reference: reference name is undefined") raise PaftacularError("Invalid loss_type for serialization")
[docs] @staticmethod def parse(loss_str: str) -> "NeutralLoss": """Parse a neutral loss string into a NeutralLoss object""" loss_str = loss_str.strip() if not loss_str: raise PaftacularError("Empty neutral loss") sign = loss_str[0] sign_mult: int if sign == "+": sign_mult = 1 elif sign == "-": sign_mult = -1 else: raise PaftacularError(f"Invalid sign in neutral loss: '{loss_str}'") content = loss_str[1:] # Remove sign # Try to parse as mass (decimal number) if _MASS_CONTENT.fullmatch(content): return NeutralLoss(sign_mult, base_mass=float(content)) # Parse as a formula: one or more atoms, each either plain (e.g. "H2O") or an # isotope-labeled atom in brackets (e.g. "[18O1]"), optionally count-prefixed, and the # two forms may be mixed (e.g. "H2[18O1]"). Tried before the reference-group branch since # a reference name can never itself start with an atom/isotope-bracket token. match = _FORMULA_CONTENT.fullmatch(content) if match: count_str, formula = match.groups() count = int(count_str) if count_str else 1 return NeutralLoss(count * sign_mult, base_formula=formula) # Parse as reference group [Name] or COUNT[Name] match = _REFERENCE_CONTENT.fullmatch(content) if match: count_str, ref_name = match.groups() count = int(count_str) if count_str else 1 return NeutralLoss(count * sign_mult, base_reference=ref_name) raise PaftacularError(f"Could not parse neutral loss: '{loss_str}'")
[docs] @dataclass(frozen=True, slots=True) class Adduct(Serializable, ScalableComposition, MassProvider): """Represents a charge-carrier adduct such as ``+Na`` or ``+2H``""" count: int base_formula: str def __post_init__(self): if type(self.count) is not int or self.count == 0: raise PaftacularError("Count must be a non-zero integer") if not isinstance(self.base_formula, str) or not self.base_formula: raise PaftacularError("Formula cannot be empty") @property def _single_composition(self) -> Counter[ElementInfo]: return formula_to_composition(self.base_formula) # Use helper! @property def formula(self) -> str: return f"{self._sign_prefix}{self.base_formula}" @property def proforma_formula(self) -> str: return composition_to_proforma_formula_string(self._single_composition) # Use helper!
[docs] def serialize(self) -> str: return f"{self._sign_prefix}{self.base_formula}"
[docs] @staticmethod def parse(s: str) -> "Adduct": """Parse a single adduct string like '+H', '+2Na', '-NH4'""" s = s.strip() match = re.fullmatch(ADDUCT_REGEX_PATTERN, s) if not match: raise PaftacularError(f"Invalid adduct: '{s}'") sign_str, count_str, formula = match.groups() sign = 1 if sign_str == "+" else -1 count = (int(count_str) if count_str else 1) * sign return Adduct(count, formula)