# tacular

> Python library of lookups for MS-proteomics reference data: post-translational
> modification ontologies (UNIMOD, PSI-MOD, RESID, XLMOD, GNOme, UniProt-PTM),
> amino acids, elements/isotopes, fragment ion types, neutral losses, proteases,
> monosaccharides, and mzPAF reference molecules. No runtime dependencies.

This is the complete usage guide for tools and agents that *use* tacular. It is
self-contained: every public name, its signature, and working examples. For
working *on* tacular, read CLAUDE.md in the repository instead.

- Repository: https://github.com/tacular-omics/tacular
- Docs: https://tacular.readthedocs.io/en/latest/
- PyPI: https://pypi.org/project/tacular/
- Version described: 1.2.x

## Install

```bash
pip install tacular        # or: uv add tacular
```

Requires Python >= 3.12. No runtime dependencies. Ships `py.typed`.

## Core idea

Every data type has one module-level singleton named `*_LOOKUP`. You index it
(`LOOKUP[key]`), test membership (`key in LOOKUP`), `.get(key)` it, and iterate it.
Entries are frozen, hashable dataclasses (`*Info`). Masses are in daltons.

Every lookup has the same mapping-style surface: `LOOKUP[key]`, `key in LOOKUP`,
`.get(key, default=None)`, `len(LOOKUP)`, `.keys() -> list`, `.values() -> list` and
iteration (over the `*Info` entries, like `.values()`).

Error policy: `LOOKUP[key]` raises `KeyError` when nothing matches, including for a
key of the wrong type (`None`, a `bool`, a float), and `ValueError` for a malformed
key (only `ELEMENT_LOOKUP`, e.g. `"c"` or `"13"`). `.get()` returns its default and
`in` returns `False` in all of these cases; `query_*` methods return `None` (or an
empty list for the list-returning ones). They never raise `AttributeError`/`TypeError`.

Hashability: every `*Info` is hashable, so entries can be set members and dict keys.
Ontology entries (`OboEntity` subclasses) hash on `(id, name)`. `dict_composition` is
shared by every caller: treat it as read-only (`to_dict()` returns a copy).

```python
import tacular as t

t.AA_LOOKUP["A"].monoisotopic_mass            # 71.0371137851
t.ELEMENT_LOOKUP["13C"].mass                   # 13.00335483507
t.UNIMOD_LOOKUP["Phospho"].formula             # 'HO3P'
[m.name for m in t.UNIMOD_LOOKUP.query_mass(79.9663, tolerance=0.001)]  # ['Phospho']
```

| singleton | class | entries | keys accepted by `LOOKUP[key]` |
|---|---|---|---|
| `UNIMOD_LOOKUP` | `UnimodLookup` | ~1560 | name (case-insensitive), id `21` / `"21"` / `"021"` |
| `PSIMOD_LOOKUP` | `PsimodLookup` | ~1607 | name, id `46` / `"00046"` |
| `RESID_LOOKUP` | `ResidLookup` | ~534 | name, id `"AA0002"` / `"0002"` / `2` |
| `XLMOD_LOOKUP` | `XlModLookup` | ~189 | name, id `"01000"` / `1000` |
| `GNO_LOOKUP` | `GnoLookup` | ~3534 | name, id `"G00008BG"` / `"00008BG"` |
| `UNIPROT_PTM_LOOKUP` | `UniprotPtmLookup` | ~440 | name, id `"0253"` / `253` |
| `AA_LOOKUP` | `AALookup` | 26 | one-letter, three-letter, name |
| `ELEMENT_LOOKUP` | `ElementLookup` | 472 (118 elements) | `"C"`, `"13C"`, `("C", 13)`, `Element.C` |
| `FRAGMENT_ION_LOOKUP` | `FragmentIonLookup` | 38 | id `"y"`, name `"y-ion"`, `IonType.Y` |
| `NEUTRAL_DELTA_LOOKUP` | `NeutralDeltaLookup` | 13 | formula `"H2O"`, name `"Water"`, `NeutralDelta.WATER` |
| `PROTEASE_LOOKUP` | `ProteaseLookup` | 19 | id `"trypsin"`, name `"Trypsin"` |
| `MONOSACCHARIDE_LOOKUP` | `MonosaccharideLookup` | 24 | ProForma name `"Hex"`, `"HexNAc"` (case-insensitive) |
| `REFMOL_LOOKUP` | `RefMolLookup` | 71 | name `"TMT126"`, `RefMolID.TMT126` |

## Modification ontologies

The six ontology lookups share one base class, `OntologyLookup[T]`, and their
entries share the `OboEntity` base dataclass.

### OboEntity (base of UnimodInfo, PsimodInfo, ResidInfo, XlModInfo, GnoInfo, UniprotPtmInfo)

Fields:

- `id: str` - raw ontology id without prefix (UNIMOD `"21"`, PSI-MOD `"00046"`, RESID `"AA0002"`, GNO `"G00008BG"`)
- `name: str`
- `formula: str | None` - ProForma-style formula, e.g. `"HO3P"`
- `monoisotopic_mass: float | None`
- `average_mass: float | None`
- `dict_composition: Mapping[str, int] | None` - element/isotope symbol -> count, e.g. `{"H": 1, "O": 3, "P": 1}`

Methods and properties:

- `.composition -> dict[ElementInfo, int] | None` - `dict_composition` with keys resolved to `ElementInfo`
- `.mass(monoisotopic: bool = True) -> float | None` - monoisotopic or average mass
- `.get_mass(monoisotopic: bool = True) -> float | None` - same as `.mass()`; the name the other `*Info` classes use
- `.id_tag -> str` - id with prefix and leading zeros stripped (`"00046"` -> `"46"`)
- `.to_dict(float_precision: int | None = 6) -> dict[str, object]` - JSON-ready dict; `"composition"` is a copy of `dict_composition`
- `OboEntity.from_dict(data) -> Self` - inverse of `to_dict`
- `.update(**kwargs) -> Self` - copy with fields replaced (entries are frozen)

### OntologyLookup methods (all six ontology lookups)

`OntologyLookup` is exported from `tacular` (the base class of `UnimodLookup` etc.).

- `lookup[key: str | int] -> T` - tries name first (case-insensitive), then id; raises `KeyError`
- `key in lookup -> bool`
- `.get(key, default=None) -> T | None`
- `.query_id(mod_id: str | int) -> T | None` - id only; strips the accession prefix (`UNIMOD:`, `MOD:`, `XLMOD:`, `RESID:`, `GNO:`, `PTM-`), the RESID `AA` / GNO `G` prefix, leading zeros and surrounding whitespace. A numeric id must be plain ASCII digits (`"+21"`, `"2_1"`, `"٢١"` do not match); a `bool` never matches
- `.query_name(name: str) -> T | None` - name only, case-insensitive; `None` for a non-`str`
- `.query_mass(mass: float, tolerance: float = 0.01, monoisotopic: bool = True) -> list[T]` - all entries within +/- tolerance Da
- `.choice(require_monoisotopic_mass: bool = True, require_composition: bool = True) -> T` - random entry
- `.values() -> list[T]`, `.keys() -> list[str]` (lower-cased names), `iter(lookup)`, `len(lookup)`
- `.version -> str` - ontology release of the active data (bundled or refreshed cache)

```python
import tacular as t

phospho = t.UNIMOD_LOOKUP["Phospho"]
print(phospho.id, phospho.formula, phospho.monoisotopic_mass)   # 21 HO3P 79.966331
print(phospho.dict_composition)                                  # {'H': 1, 'O': 3, 'P': 1}
print(t.UNIMOD_LOOKUP[21].name)                                  # Phospho
print(t.UNIMOD_LOOKUP["oxidation"].id)                           # 35
print(t.UNIMOD_LOOKUP.get("not a mod"))                          # None

# Identify a mass shift
hits = t.UNIMOD_LOOKUP.query_mass(79.9663, tolerance=0.001)
print([h.name for h in hits])                                    # ['Phospho']
avg_hits = t.UNIMOD_LOOKUP.query_mass(42.01, tolerance=0.02, monoisotopic=False)

print(t.PSIMOD_LOOKUP["00046"].name)                             # O-phospho-L-serine
print(t.RESID_LOOKUP["AA0002"].name)                             # L-arginine residue
print(t.GNO_LOOKUP["G00008BG"].id)                               # G00008BG
print(t.UNIMOD_LOOKUP.version, len(t.UNIMOD_LOOKUP))
```

### UniprotPtmInfo (extra fields)

`UniprotPtmInfo(OboEntity)` adds the UniProt `ptmlist.txt` fields:
`feature_key` (e.g. `"MOD_RES"`), `target` (e.g. `"Serine."`),
`position_aa`, `position_polypeptide` (e.g. `"Anywhere."`), `cellular_location`,
`taxonomic_range: tuple[str, ...]`, `keywords: tuple[str, ...]`,
`cross_references: tuple[str, ...]` (e.g. `('PSI-MOD; MOD:00046.', 'Unimod; 21.')`).

- `.residue -> AminoAcid | None` - `target` as a single amino acid
- `.location -> ModLocation | None` - `position_polypeptide` as an enum
- `.has_unimod`, `.has_psimod -> bool`
- `.get_unimod() -> UnimodInfo | None`, `.get_psimod() -> PsimodInfo | None` - follow the cross-reference

`ModLocation` (exported from `tacular`) is a `StrEnum` of UniProt `position_polypeptide`
values: `ANYWHERE` (`"Anywhere."`), `NTERM` (`"N-terminal."`), `CTERM` (`"C-terminal."`),
`PROTEIN_CORE` (`"Protein core."`).

```python
import tacular as t

ps = next(p for p in t.UNIPROT_PTM_LOOKUP if p.name == "Phosphoserine")
print(ps.residue, ps.get_unimod().name, ps.get_psimod().name)    # S Phospho O-phospho-L-serine
```

## Amino acids

- `AA_LOOKUP: AALookup`
- `AminoAcid` - `StrEnum` of one-letter codes `A`-`Z` (includes ambiguous `B`, `J`, `X`, `Z` and `O`, `U`)
- `AminoAcidInfo(id, name, three_letter_code, formula, monoisotopic_mass, average_mass, dict_composition, is_mass_ambiguous=False, is_ambiguous=False)`
  - `.one_letter_code`, `.get_mass(monoisotopic=True) -> float | None`, `.to_dict(float_precision=6)`
- `AMINO_ACID_INFOS: dict[AminoAcid, AminoAcidInfo]`, `ORDERED_AMINO_ACIDS: list[AminoAcid]`

`AALookup` methods: `lookup[key]` (one-letter, then three-letter, then name; all
case-insensitive), `in`, `.get(key, default=None)`, `len`, `.keys()` (one-letter codes),
`.values()`, iteration (A-Z order),
`.one_letter(code)`, `.three_letter(code)`, `.name(name)`,
`.mass(key, monoisotopic=True) -> float`, `.composition(key) -> dict[ElementInfo, int]`,
`.is_ambiguous(key)`, `.is_unambiguous(key)`, `.is_mass_ambiguous(key)`,
`.is_mass_unambiguous(key)`. Tuples: `.ordered_amino_acids`,
`.ambiguous_amino_acids`, `.unambiguous_amino_acids`, `.mass_amino_acids`,
`.mass_unambiguous_amino_acids`.

```python
import tacular as t

ala = t.AA_LOOKUP["Ala"]
print(ala.id, ala.name, ala.formula, ala.monoisotopic_mass)      # A Alanine C3H5NO 71.0371137851
print(t.AA_LOOKUP.mass("K"))                                     # 128.0949630152
print(t.AA_LOOKUP["B"].monoisotopic_mass)                        # None (Asx is mass-ambiguous)
print(t.AA_LOOKUP["J"].monoisotopic_mass)                        # 113.0840639785 (Leu/Ile)
print(t.AA_LOOKUP["X"].monoisotopic_mass)                        # 0.0
```

## Elements and isotopes

- `ELEMENT_LOOKUP: ElementLookup`
- `Element` - enum of element symbols
- `ElementInfo(number, mass_number, symbol, mass, abundance, average_mass, is_monoisotopic)`
  - `mass_number is None` means the element as a whole (its `mass` is the monoisotopic isotope's mass)
  - `.get_mass(monoisotopic=True)`, `.neutron_count`, `.proton_count`, `.is_radioactive`,
    `.serialize(count) -> str` (ProForma formula token, e.g. `"[13C2]"`), `.to_dict()`, `.update(**kw)`
- `parse_composition(comp_dict: Mapping[str, int]) -> dict[ElementInfo, int]`

`ElementLookup` methods: `lookup[key]` with key `"C"`, `"13C"`, `("C", 13)`,
`("C", None)` or `Element.C`; `in`, `.get(key, default=None)`, `len`, iteration,
`.keys()` (`(Element, mass_number)` tuples), `.values()`,
`.mass(key, monoisotopic=True) -> float`, `.get_monoisotopic(symbol)`,
`.get_isotope(symbol, mass_number)`, `.get_all_isotopes(symbol)`,
`.get_elements() -> list[str]`, `.get_masses_and_abundances(key) -> list[(mass, abundance)]`,
`.get_neutron_offsets_and_abundances(key) -> list[(offset, abundance)]`.

```python
import tacular as t

print(t.ELEMENT_LOOKUP["C"].mass, t.ELEMENT_LOOKUP["C"].average_mass)   # 12.0 12.0107...
print(t.ELEMENT_LOOKUP[("C", 13)].mass)                                  # 13.00335483507
print(t.ELEMENT_LOOKUP.get_masses_and_abundances("C"))
# [(12.0, 0.9893), (13.00335483507, 0.0107), (14.0032419884, 0.0)]
print(t.ELEMENT_LOOKUP["D"].mass_number)                                 # 2 ("D"/"T" alias H-2/H-3)
comp = t.parse_composition({"C": 2, "H": 4})
print(sum(e.mass * n for e, n in comp.items()))                          # 28.0313...
```

## Fragment ion types

- `FRAGMENT_ION_LOOKUP: FragmentIonLookup`
- `IonType` - enum; values are mzPAF/ProForma ion codes: `p` (precursor), `n`, `a b c x y z`,
  `z.`, `z+H`, `c-H`, `i` (immonium), satellite ions `d`, `v`, `w`, `da`, `db`, `wa`, `wb`
  (and residue-specific variants such as `d-valine`), internal ions `by ax ay az bx bz cx cy cz`
- `IonTypeLiteral` - `Literal[...]` of those values; `IonTypeProperty` - flag enum
  (`FORWARD`, `BACKWARD`, `INTERNAL`, `INTACT`, `AA_SPECIFIC_FWD`, `AA_SPECIFIC_BWD`)
- `FragmentIonInfo(id, name, formula, monoisotopic_mass, average_mass, dict_composition, properties)`
  - `.ion_type -> IonType`, `.is_forward`, `.is_backward`, `.is_internal`, `.is_intact`,
    `.is_aa_specific_forward`, `.is_aa_specific_backward`, `.get_mass(monoisotopic=True)`
- Lookup: `lookup[key]` by id, name or `IonType`; `.get(key, default=None)`, `in`, `len`,
  iteration, `.keys()` (ids), `.values()`, `.query_id(ion_id)`, `.query_name(name)`, `.query_ion_type(ion_type)`

Masses are neutral offsets added to the summed residue masses: `b` = 0.0,
`a` = -27.9949 (-CO), `c` = +17.0265 (+NH3), `y` = +18.0106 (+H2O), `p` = +18.0106.

```python
import tacular as t

y = t.FRAGMENT_ION_LOOKUP["y"]
print(y.name, y.monoisotopic_mass, y.is_backward)                # y-ion 18.010565 True
print(t.FRAGMENT_ION_LOOKUP[t.IonType.B].is_forward)            # True
print(t.FRAGMENT_ION_LOOKUP["by"].is_internal)                  # True
```

## Neutral losses

- `NEUTRAL_DELTA_LOOKUP: NeutralDeltaLookup`, `NEUTRAL_DELTA_DICT: dict[NeutralDelta, NeutralDeltaInfo]`
- `NeutralDelta` - enum: `H NH3 H2O CO CO2 HCONH2 HCOOH CH4OS SO3 HPO3 C2H5NOS C2H4O2S H3PO4`
- `NeutralDeltaLiteral` - `Literal[...]` of those formulas
- `NeutralDeltaInfo(formula, name, description, amino_acids: frozenset[str], monoisotopic_mass, average_mass, dict_composition)`
  - masses are **signed** (a loss is negative: water = -18.0106)
  - `.calculate_loss_sites(sequence) -> int` - count of residues in `sequence` that can lose it
  - `.get_mass(monoisotopic=True)`, `.to_dict()`
- Lookup: `lookup[key]` by formula, name (case-insensitive) or enum; `.get(key, default=None)`,
  `in`, `len`, iteration, `.keys()` (formulas), `.values()`, `.query_formula(formula)`, `.query_name(name)`, `.query_delta(delta)`

```python
import tacular as t

water = t.NEUTRAL_DELTA_LOOKUP["H2O"]
print(water.name, water.monoisotopic_mass)                       # Water -18.01056468403
print(sorted(water.amino_acids))                                 # ['D', 'E', 'S', 'T']
print(water.calculate_loss_sites("PEPTIDE"))                     # 4
print(t.NEUTRAL_DELTA_LOOKUP["ammonia"].formula)                 # NH3
```

## Proteases

- `PROTEASE_LOOKUP: ProteaseLookup`
- `Proteases` - enum of ids: `arg_c asp_n chymotrypsin chymotrypsin_low chymotrypsin_promega_high
  chymotrypsin_promega_low glu_c lys_c lys_n proteinase_k trypsin trypsin_full proalanase
  proalanase_low elastase pepsin thermolysin unspecific no_enzyme`
- `PROTEASE_LITERALS` - `Literal[...]` of those ids; `PROTEASES_DICT: dict[Proteases, ProteaseInfo]`
- `ProteaseInfo(id, name, full_name, regex)` - `.regex` is a zero-width cleavage-site regex;
  `.pattern` is the compiled `re.Pattern`; `.to_dict()`
- Lookup: `lookup[key]` by id or name; `.get(key, default=None)`, `in`, `len`, iteration,
  `.keys()` (ids), `.values()`, `.query_id`, `.query_name`

```python
import re
import tacular as t

trypsin = t.PROTEASE_LOOKUP["trypsin"]
print(trypsin.regex)                                             # (?<=[KR])(?=[^P])
print(re.split(trypsin.pattern, "PEPTIDEKAAARPEPK"))             # ['PEPTIDEK', 'AAARPEPK']
print(t.PROTEASE_LOOKUP["trypsin_full"].regex)                   # (?<=[KR])  (cleaves before P too)
```

Cleavage is regex-based; digestion (missed cleavages, length filters) lives in
`peptacular`, not here.

## Monosaccharides

- `MONOSACCHARIDE_LOOKUP: MonosaccharideLookup`
- `Monosaccharide` - enum of ProForma glycan names (`Hex`, `HexNAc`, `dHex`, `Fuc`, `NeuAc`,
  `NeuGc`, `HexN`, `aHex`, `Pen`, `Sulfate`, `Phosphate`, ...)
- `MonosaccharideInfo(OboEntity)` - residue formula and masses; `.mass()` / `.get_mass()`
- Lookup: `lookup[key]` by ProForma name (case-insensitive) or enum; `.get(key, default=None)`, `in`,
  `len`, iteration, `.keys()` (ProForma names), `.values()`, `.proforma(name)`

```python
import tacular as t

print(t.MONOSACCHARIDE_LOOKUP["Hex"].monoisotopic_mass)          # 162.052823418
print(t.MONOSACCHARIDE_LOOKUP["hexnac"].name)                    # HexNAc
```

## mzPAF reference molecules

- `REFMOL_LOOKUP: RefMolLookup`
- `RefMolID` - enum (TMT/TMTpro/iTRAQ reporter ions, nucleobases, `sidechain_<AA>` ions, ...);
  `RefMolLiteral` - `Literal[...]` of the names
- `RefMolInfo(name, label_type, molecule_type, chemical_formula, monoisotopic_mass, average_mass, dict_composition)`
  - `molecule_type` is one of `reporter`, `reporter+balance`, `sidechain`, `nucleobase`
  - `.get_mass(monoisotopic=True)`, `.to_dict()`
- Lookup: `lookup[key]` by name or enum; `.get(key, default=None)`, `in`, `len`, iteration,
  `.keys()` (names), `.values()`, `.query_name(name)`,
  `.query_id(RefMolID)`, `.query_label_type(label_type) -> list`, `.query_molecule_type(t) -> list`

```python
import tacular as t

tmt = t.REFMOL_LOOKUP["TMT126"]
print(tmt.chemical_formula, round(tmt.monoisotopic_mass, 4))     # C8N1H15 125.1204
print(len(t.REFMOL_LOOKUP.query_molecule_type("reporter")) > 0)  # True
```

## Refreshing ontology data (CLI)

Bundled ontology snapshots can be replaced by the latest upstream release without
reinstalling. The refresh is written to a per-user cache and used on the next
`import tacular`.

```bash
tacular update                     # refresh all six: unimod xlmod psimod resid gno uniprot_ptm
                                   # (includes GNOme, a large ~129 MB download)
tacular update unimod xlmod        # refresh a subset
tacular update --offline DIR       # regenerate from local source files in DIR, no network
tacular status                     # bundled vs cached version and entry count per ontology
tacular clear                      # delete the cache, revert to bundled data
tacular where                      # print the cache directory
tacular -v update ...              # -v info, -vv debug
python -m tacular status           # same CLI
```

Environment variables:

- `TACULAR_DATA_DIR` - cache directory (default `$XDG_CACHE_HOME/tacular`, else `~/.cache/tacular`)
- `TACULAR_DISABLE_CACHE=1` - ignore any cache and always use bundled data

An unreadable cache logs a warning and falls back to bundled data.

## Gotchas

1. **Id queries accept each ontology's own accession prefix** (case-insensitive):
   `UNIMOD:21`, `MOD:00046`, `XLMOD:01000`, `RESID:AA0002`, `GNO:G00008BG`, `PTM-0476`.
   Another ontology's prefix is not stripped (`UNIMOD_LOOKUP["MOD:00046"]` raises `KeyError`).
2. `LOOKUP[key]` tries the **name before the id**, so a name that looks like a number wins.
3. Ontology entries may have `None` formula/mass/composition. `query_mass` skips them;
   `choice()` excludes them by default.
4. `AA_LOOKUP["B"]` and `["Z"]` have mass `None`; `["X"]` has mass `0.0` and is not
   flagged ambiguous; `["J"]` has the Leu/Ile mass.
5. `ELEMENT_LOOKUP["C"]` is the element (mass = 12C mass). Use `"13C"` or `("C", 13)` for
   an isotope; `"C13"` raises `KeyError`. Element symbols are case-sensitive; everything
   else is case-insensitive.
6. Neutral-loss masses are negative; fragment-ion masses are offsets from the residue sum.
7. All `*Info` objects are frozen dataclasses. Use `.update(**kwargs)` for a modified copy.
8. After `tacular update`, `.version` and entry counts change. Pin behaviour in tests with
   `TACULAR_DISABLE_CACHE=1`.

## Related packages

- `peptacular` - ProForma 2.0 peptide parsing, mass/m/z, fragmentation, digestion (uses tacular)
- `paftacular` - mzPAF peak-annotation parsing and serialization (uses tacular)
