# 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: 2.0.x (migrating from 1.x: https://tacular.readthedocs.io/en/latest/migration.html)

## 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`,
`.items() -> list[tuple]` and iteration (over the `*Info` entries, like `.values()`).

Error policy: every error tacular raises is a `tacular.TacularError` (a `ValueError`).
`LOOKUP[key]` raises `tacular.TacularKeyError` (a `TacularError` that is also a
`KeyError`) when nothing matches, for a malformed key (e.g. `ELEMENT_LOOKUP["c"]` or
`["13"]`) and for a key of the wrong type (`None`, a `bool`, a float, a bad tuple).
Asking for a mass or composition an entry lacks raises `TacularError`. `.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).

Options are keyword-only: `get_mass(monoisotopic=False)`, `to_dict(float_precision=None)`,
`query_mass(m, tolerance=0.1)`. Physical constants: `from tacular.constants import
PROTON_MASS, ELECTRON_MASS, NEUTRON_MASS, HYDROGEN_MASS, C13_C12_MASS_DIFF` (CODATA 2018 /
AME2016).

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
a read-only dict (mutating it raises `TypeError`); `to_dict()` returns a plain 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"` / `"UNIMOD:21"` / `"U:21"` |
| `PSIMOD_LOOKUP` | `PsimodLookup` | ~1607 | name, id `46` / `"00046"` / `"MOD:00046"` / `"M:00046"` |
| `RESID_LOOKUP` | `ResidLookup` | ~534 | name, id `"AA0002"` / `"0002"` / `2` / `"RESID:AA0002"` / `"R:AA0002"` |
| `XLMOD_LOOKUP` | `XlmodLookup` | ~189 | name, id `"01000"` / `1000` / `"XLMOD:01000"` / `"X:01000"` |
| `GNO_LOOKUP` | `GnoLookup` | ~3534 | name, id `"G00008BG"` / `"00008BG"` / `"GNO:G00008BG"` / `"G:G00008BG"` |
| `UNIPROT_PTM_LOOKUP` | `UniprotPtmLookup` | ~440 | name, id `"0253"` / `253` / `"PTM-0253"` |
| `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`
- `.get_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; `"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; both accept the accession prefixes; raises `TacularKeyError`
- `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:`/`U:`, `MOD:`/`M:`, `XLMOD:`/`X:`, `RESID:`/`R:`, `GNO:`/`G:`, `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, optional accession prefix (`"U:Phospho"`); `None` for a non-`str`
- `.query_mass(mass: float, *, tolerance: float = 0.01, tolerance_unit: Literal["da", "ppm"] = "da", monoisotopic: bool = True) -> list[T]` - all entries within +/- tolerance (Da, or ppm of `mass` with `tolerance_unit="ppm"`), in data order
- `.choice(*, require_monoisotopic_mass: bool = True, require_composition: bool = True) -> T` - random entry; `TacularError` if none qualifies
- `.values() -> list[T]`, `.keys() -> list[str]` (raw ids, e.g. `"21"`), `.items()`, `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)`
    (includes `is_mass_ambiguous` and `is_ambiguous`)
- `AMINO_ACID_INFOS: dict[AminoAcid, AminoAcidInfo]`, `ORDERED_AMINO_ACIDS: list[str]`

`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()`, `.items()`, iteration (A-Z order),
`.query_one_letter(code)`, `.query_three_letter(code)`, `.query_name(name)` (each `-> AminoAcidInfo | None`),
`.get_mass(key, *, monoisotopic=True) -> float` (`TacularError` for `B`/`Z`),
`.composition(key) -> Counter[ElementInfo]`,
`.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.get_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` (`TacularError` on an element entry),
    `.proton_count`, `.is_radioactive`, `.serialize(count) -> str` (ProForma formula token,
    e.g. `"[13C2]"`), `.to_dict(*, float_precision=6)` (includes `is_monoisotopic`), `.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()`, `.items()`,
`.get_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)]` (abundance `0.0` for synthetic isotopes).
`ElementKey` is the type alias of every accepted key.

```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()`, `.items()`, `.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()` (`"composition"`, sorted `"amino_acids"`)
- Lookup: `lookup[key]` by formula, name (case-insensitive) or enum; `.get(key, default=None)`,
  `in`, `len`, iteration, `.keys()` (formulas), `.values()`, `.items()`, `.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`
- `Protease` - 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`
- `ProteaseLiteral` - `Literal[...]` of those ids; `PROTEASE_DICT: dict[Protease, 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()`, `.items()`, `.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; `.get_mass(*, monoisotopic=True)`
- Lookup: `lookup[key]` by ProForma name (case-insensitive) or enum; `.get(key, default=None)`, `in`,
  `len`, iteration, `.keys()` (ProForma names), `.values()`, `.items()`, `.query_name(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, 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()`, `.items()`, `.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.formula, round(tmt.monoisotopic_mass, 4))              # C8N1H15 125.1204
print(len(t.REFMOL_LOOKUP.query_molecule_type("reporter")) > 0)  # True
```

## Mass tolerances

`tacular.tolerance`; every name is also exported from `tacular`. Units are the lowercase
strings `"da"` and `"ppm"` (`ToleranceUnit`); anything else raises `TacularError`. ppm is
relative to `abs(mass)`, so windows around negative masses are symmetric.

- `ppm_error(observed, theoretical) -> float` - `(observed - theoretical) / abs(theoretical) * 1e6`; `theoretical == 0` raises `TacularError`
- `da_to_ppm(delta, mz) -> float` - `delta / abs(mz) * 1e6`; `mz == 0` raises `TacularError`
- `ppm_to_da(delta_ppm, mz) -> float` - `delta_ppm * abs(mz) / 1e6`
- `tolerance_window(mass, tolerance, *, tolerance_unit="da") -> tuple[float, float]` - `(lo, hi)`; a negative tolerance gives `lo > hi`
- `within_tolerance(observed, theoretical, tolerance, *, tolerance_unit="da") -> bool` - `lo <= observed <= hi` on `tolerance_window(theoretical, ...)` (bounds included, so the two always agree); ppm of `theoretical`
- `tolerance_window` and `within_tolerance` raise `TacularError` for NaN or infinite input; `da_to_ppm`/`ppm_to_da` use `abs(mz)` (spxtacular's `da_to_ppm` uses the signed m/z)

`OntologyLookup.query_mass(..., tolerance_unit=)` uses the same window.

```python
import tacular as t

print(round(t.ppm_error(1000.01, 1000.0), 6))                # 10.0
print(t.tolerance_window(100.0, 0.5))                        # (99.5, 100.5)
print(t.within_tolerance(1000.005, 1000.0, 10, tolerance_unit="ppm"))  # True
```

## Shared types

`tacular.types`, for the other tacular-omics packages to import instead of defining their
own: `ToleranceUnit = Literal["da", "ppm"]` (same object as `tacular.tolerance.ToleranceUnit`)
and `Polarity = Literal["positive", "negative"]`. Both are also exported from `tacular`.

```python
from tacular.types import Polarity, ToleranceUnit
```

## Quantitative labels (isobaric tags, SILAC)

`tacular.labels`; every name is also exported from `tacular`. Compositions come from
UNIMOD; masses and reporter m/z are computed from the element table (TMT/TMTpro reporter m/z agree
with Thermo's TMTpro guide MAN0018773 to 2e-6; iTRAQ m/z are computed and legacy 4-decimal
iTRAQ tables are ~0.0005 higher, as if the electron were not subtracted; `average_mass`
differs from UNIMOD's by up to 6e-4 Da because the element tables differ).

- `ISOBARIC_TAG_LOOKUP` (`IsobaricTagLookup`): plexes `TMT0 TMT2 TMT6 TMT10 TMT11 TMTpro0 TMT16 TMT18 iTRAQ4 iTRAQ8`;
  aliases such as `TMT10plex`, `TMTpro16`, `TMTpro18`, `TMTzero`, `TMTpro_zero`, `iTRAQ8plex` (case-insensitive).
  `.query_name(name)`, `.query_unimod_id(737 | "UNIMOD:737") -> list` (TMT6/10/11 share TMT6plex).
- `IsobaricTagInfo`: `name, unimod_id, unimod_name, dict_composition, reporter_ions, aliases,
  monoisotopic_mass, average_mass`; `.plex`, `.channels`, `.reporter_mzs`, `.query_reporter(channel)` (or `None`),
  `.get_mass(*, monoisotopic=True)`, `.composition`, `.to_dict()`
- `ReporterIonInfo`: `channel` (`"126"`, `"127N"`, `"134C"`, `"114"`), `dict_composition` (the 1+ ion,
  e.g. C8H16N for TMT), `mz` (composition mass minus one electron); the channel's UNIMOD tag
  `tag_unimod_id, tag_unimod_name, tag_dict_composition, tag_monoisotopic_mass` (iTRAQ
  4-plex 114/115 = 532/533, 116/117 = 214; 8-plex 115/118/119/121 = 731, others 730;
  TMT channels use the plex's entry)
- `SILAC_LOOKUP` (`SilacLabelLookup`): `Lys4` (2H4, UNIMOD:481), `Lys6` (13C6, 188), `Lys8`
  (13C6 15N2, 259), `Arg6` (13C6, 188), `Arg10` (13C6 15N4, 267); aliases `Lys+8`, `K+8`, `K8`.
  `.query_residue("K")`, `.query_unimod_id(...)`, `.set_names`, `.query_set(name)` (or `None`),
  `.get_set(name)` (raises `TacularKeyError`): `light` = `()`, `medium` = Lys4 + Arg6, `heavy` = Lys8 + Arg10
- `SilacLabelInfo`: `name, residue, unimod_id, unimod_name, dict_composition, aliases,
  monoisotopic_mass, average_mass`; `.get_mass(*, monoisotopic=True)`, `.composition`, `.to_dict()`

```python
import tacular as t

tmt = t.ISOBARIC_TAG_LOOKUP["TMTpro18"]
print(tmt.name, tmt.unimod_id, tmt.plex)            # TMT18 2016 18
print(round(tmt.query_reporter("135N").mz, 6))            # 135.1516
print([s.name for s in t.SILAC_LOOKUP.get_set("heavy")])  # ['Lys8', 'Arg10']
print(round(t.SILAC_LOOKUP["K+8"].monoisotopic_mass, 6))  # 8.014199
```

## 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 cached data and downloads, 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 and name queries accept each ontology's own accession prefix**, long or short,
   case-insensitive: `UNIMOD:21`/`U:21`, `MOD:00046`/`M:00046`, `XLMOD:01000`/`X:01000`,
   `RESID:AA0002`/`R:AA0002`, `GNO:G00008BG`/`G:G00008BG`, `PTM-0476`, `U:Phospho`.
   Another ontology's prefix is not stripped (`UNIMOD_LOOKUP["MOD:00046"]` raises `TacularKeyError`).
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 `TacularKeyError`. 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 `dataclasses.replace(info, ...)` (or
   `.update(**kwargs)` on ontology entries and `ElementInfo`) 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)
