"""Shared base class and helpers for ontology entries (UNIMOD, PSI-MOD, RESID, XLMOD,
GNOme, amino acids, elements, ...). Every ``*Info`` dataclass in this package
(``UnimodInfo``, ``PsimodInfo``, ``ElementInfo``, ...) subclasses :class:`OboEntity`
and inherits its fields, serialization, and mass/composition helpers.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Self, TypeVar
from .elements import ElementInfo, parse_composition
T = TypeVar("T", bound="OboEntity")
[docs]
@dataclass(frozen=True, slots=True)
class OboEntity:
"""Base class for OBO file entities.
Subclasses (one per ontology/data type) add no fields of their own beyond
what's declared here; they exist to give each ontology's entries a distinct
type and, where needed, override :attr:`id_tag` for that ontology's id format.
"""
id: str
"""The entry's id, in whatever format its source ontology uses (e.g. ``"536"``
for UNIMOD, ``"AA0001"`` for RESID). Use :attr:`id_tag` for a normalized form."""
name: str
"""The entry's human-readable name, as given by the source ontology."""
formula: str | None
"""Chemical formula string (e.g. ``"C2H2O"``), or ``None`` if not available."""
monoisotopic_mass: float | None
"""Monoisotopic mass delta in Da, or ``None`` if not available."""
average_mass: float | None
"""Average (isotope-abundance-weighted) mass delta in Da, or ``None`` if not available."""
dict_composition: Mapping[str, int] | None
"""Elemental composition as ``{symbol: count}`` (isotope keys like ``"13C"`` are
supported), or ``None`` if not available. Use :attr:`composition` for a version
keyed by :class:`~tacular.ElementInfo` instead of plain strings."""
def __str__(self) -> str:
"""Return ``"{name} ({formula})"``, e.g. ``"Acetyl (C2H2O)"``."""
return f"{self.name} ({self.formula})"
@property
def composition(self) -> dict[ElementInfo, int] | None:
"""``dict_composition`` with keys resolved to :class:`~tacular.ElementInfo`
objects instead of plain symbol strings; ``None`` if no composition is set."""
if self.dict_composition is None:
return None
return parse_composition(self.dict_composition)
def __repr__(self) -> str:
"""Return an eval-ish repr including id, name, formula, masses, and composition."""
return (
f"{self.__class__.__name__}(id={self.id}, name={self.name}, formula={self.formula}, "
f"monoisotopic_mass={self.monoisotopic_mass}, average_mass={self.average_mass}, "
f"composition={self.dict_composition})"
)
[docs]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> Self:
"""Reconstruct an OboEntity from its ``to_dict`` representation.
The inverse of :meth:`to_dict`; note ``to_dict`` serialises
``dict_composition`` under the ``"composition"`` key.
"""
return cls(
id=data["id"],
name=data["name"],
formula=data.get("formula"),
monoisotopic_mass=data.get("monoisotopic_mass"),
average_mass=data.get("average_mass"),
dict_composition=data.get("composition"),
)
[docs]
def update(self, **kwargs: Any) -> Self:
"""Return a new instance with updated fields"""
return self.__class__(
id=kwargs.get("id", self.id),
name=kwargs.get("name", self.name),
formula=kwargs.get("formula", self.formula),
monoisotopic_mass=kwargs.get("monoisotopic_mass", self.monoisotopic_mass),
average_mass=kwargs.get("average_mass", self.average_mass),
dict_composition=kwargs.get("dict_composition", self.dict_composition),
)
[docs]
def mass(self, monoisotopic: bool = True) -> float | None:
"""Get the mass of the entity"""
return self.monoisotopic_mass if monoisotopic else self.average_mass
[docs]
def to_dict(self, float_precision: int | None = 6) -> dict[str, object]:
"""Convert the OboEntity to a dictionary.
``float_precision`` rounds the masses (default 6, as used for the bundled
``jsons/*.json``). Pass ``None`` to preserve full float precision, e.g. when
round-tripping through the runtime cache so an updated install matches the
precision of the bundled ``data.py``.
"""
def _round(value: float | None) -> float | None:
if value is None or float_precision is None:
return value
return round(value, float_precision)
return {
"id": self.id,
"name": self.name,
"formula": self.formula,
"monoisotopic_mass": _round(self.monoisotopic_mass),
"average_mass": _round(self.average_mass),
"composition": self.dict_composition,
}
def __hash__(self) -> int:
"""Hash on ``(id, name)`` only, so equal entries hash equal even if a mass
field was later updated via :meth:`update`."""
return hash(
(
self.id,
self.name,
)
)
@property
def id_tag(self) -> str:
"""``id`` with leading zeros stripped (e.g. ``"00042"`` -> ``"42"``).
Subclasses whose ids carry a non-numeric prefix (RESID's ``"AA0001"``, for
example) override this to strip that prefix too.
"""
return self.id.lstrip("0")
[docs]
def filter_infos[T: OboEntity](
infos: list[T],
has_monoisotopic_mass: bool | None = None,
has_composition: bool | None = None,
**criteria: Any,
) -> list[T]:
"""Filter a list of OboEntity or its subclasses based on criteria."""
filtered: list[T] = []
for info in infos:
match = True
# Check monoisotopic mass requirement
if has_monoisotopic_mass is not None:
if has_monoisotopic_mass and info.monoisotopic_mass is None:
match = False
elif not has_monoisotopic_mass and info.monoisotopic_mass is not None:
match = False
# Check composition requirement
if match and has_composition is not None:
if has_composition and info.dict_composition is None:
match = False
elif not has_composition and info.dict_composition is not None:
match = False
# Check other criteria
if match:
for key, value in criteria.items():
if not hasattr(info, key) or getattr(info, key) != value:
match = False
break
if match:
filtered.append(info)
return filtered