# 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.1.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 dataclasses (`*Info`). Masses are in daltons.

```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` | ~1558 | name, id `46` / `"00046"` |
| `RESID_LOOKUP` | `ResidLookup` | ~535 | 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` | ~438 | 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
- `.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
- `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)

- `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 and leading zeros
- `.query_name(name: str) -> T | None` - name only, case-insensitive
- `.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

```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)`, 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`, `len`, iteration, `.keys()`, `.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`, `in`, iteration,
  `.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; `in`, `len`, iteration,
  `.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` (a `ProteaseLookup`; the class itself is not exported)
- `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`, `in`, `len`, iteration, `.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
- Lookup: `lookup[key]` by ProForma name (case-insensitive) or enum; `.get`, `in`, iteration, `.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`, `in`, iteration, `.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)
