# peptacular

> ProForma 2.1 peptide sequence library for Python: parse and serialize ProForma,
> edit modifications, and calculate mass, m/z, elemental composition, fragment ions
> (exportable as mzPAF), isotopic envelopes, enzymatic digests and physicochemical
> properties. Built on tacular's reference data.

This is the complete usage guide for tools and agents that *use* peptacular. It is
self-contained: the public names, their signatures, and working examples (every
`python` block below was run against the version described). To work *on*
peptacular itself, read CLAUDE.md in the repository instead.

- Repository: https://github.com/tacular-omics/peptacular
- Docs: https://peptacular.readthedocs.io/en/latest/
- PyPI: https://pypi.org/project/peptacular/
- Version described: 5.0.x
- ProForma spec: https://github.com/HUPO-PSI/ProForma

## Install

```bash
pip install peptacular              # or: uv add peptacular
pip install "peptacular[mcp]"       # local MCP server (peptacular-mcp)
pip install "peptacular[pyteomics]" # also: psm-utils, alphabase; "interop" installs all three
```

Requires Python >= 3.12. The one runtime dependency is `tacular` (`>=2.0,<3`).
Ships `py.typed`.

Import it as `pt`. Everything public is on the top-level namespace and listed in
`pt.__all__`. Of tacular, only the enums `pt.IonType`, `pt.NeutralDelta` and
`pt.Protease` are re-exported; import lookups (`UNIMOD_LOOKUP`, `PROTEASE_LOOKUP`,
`AA_LOOKUP`, ...) from `tacular` directly.

## Core idea

There are two equivalent APIs:

1. **Object API**: `pt.parse(s)` returns a `ProFormaAnnotation`. It is mutable. Its
   methods edit modifications, slice, and calculate.
2. **Functional API**: `pt.mass(x)`, `pt.fragment(x)`, `pt.digest(x)`, ... take a
   ProForma string, a `ProFormaAnnotation`, or a list of either. A list input returns a
   list. Lists of 1000 or more items run in a process pool automatically.

```python
import peptacular as pt

peptide = pt.parse("PEM[Oxidation]TIDE")
print(peptide.mass())                   # 849.3426002717299
print(peptide.mz(charge=2))             # 425.6785766024859
print(peptide.set_charge(2).set_peptide_name("Peptacular").serialize())  # (>Peptacular)PEM[Oxidation]TIDE/2

print(pt.mass("PEM[Oxidation]TIDE"))    # 849.3426002717299
print(pt.mass(["PEPTIDE", "SICK/2"]))   # [799.3599640328299, 451.24535797517194]
```

Masses are in daltons. `mass()` is the mass of the ion, including any charge: a `/2`
annotation adds two protons. `neutral_mass()` is the uncharged mass, and `mz()` is
m/z.

## ProForma in one screen

| syntax | meaning |
|---|---|
| `PEM[Oxidation]TIDE` | named mod on M (UNIMOD/PSI-MOD name lookup) |
| `PEM[UNIMOD:35]TIDE`, `[MOD:00046]`, `[R:...]`, `[G:...]`, `[X:...]` | CV accession / prefixed name |
| `PEM[+15.9949]TIDE` | delta mass (no composition) |
| `PEP[Formula:HO3P]TIDE` | formula |
| `N[Glycan:HexNAc1Hex2]` | glycan composition |
| `[Acetyl]-PEPTIDE-[Amidated]` | N-/C-terminal mods |
| `<[Carbamidomethyl]@C>PEPTCIDE` | fixed (static) mod |
| `<13C>PEPTIDE` | global isotope replacement |
| `{Glycan:Hex}PEPTIDE` | labile mod |
| `[Phospho]?PEPTIDE` | unknown-position mod |
| `PE(PT)[Phospho]IDE` | mod on an interval |
| `PEPT[Phospho#1]IDES[#1]` | ambiguous-position group |
| `PEPTIDE/2`, `PEPTIDE/[Na:z+1,H:z+1]` | charge, explicit charge carriers |
| `(>name)PEPTIDE` | peptide name (also `(>>ion)`, `(>>>compound)`) |
| `PEPTIDE/2+ELVIS/3` | chimeric (use `parse_chimeric`) |

Compliance status: https://github.com/tacular-omics/peptacular/blob/main/PROFORMA_COMPLIANCE.md

## Parse, validate, serialize

```
parse(s: str | Sequence[str], validate: bool = False, *PAR) -> ProFormaAnnotation | list[ProFormaAnnotation]
parse_chimeric(s: str | Sequence[str], validate: bool = False, *PAR) -> list[ProFormaAnnotation] | list[list[...]]
serialize(sequence: SEQS, *PAR) -> str | list[str]
serialize_chimeric(sequence: Sequence[ProFormaAnnotation | str] | Sequence[Sequence[...]], *PAR) -> str | list[str]
validate(sequence: SEQS, *PAR) -> bool | list[bool]         # False if any mod does not resolve
join(annotations, *PAR) -> str | list[str]                    # concatenate annotations
split(sequence: SEQS, *PAR) -> list[str] | list[list[str]]    # one string per residue
```

Signature shorthand used in this file:

- `SEQS` = `str | ProFormaAnnotation | Sequence[str | ProFormaAnnotation]`.
- `*PAR` = the keyword-only arguments `n_workers: int | None = None`,
  `chunksize: int | None = None`, and
  `method: "process" | "thread" | "sequential" | None = None`.
- `ION` = an `IonType` or its string: `"p"` (precursor), `"n"` (neutral), `"a"`, `"b"`,
  `"c"`, `"x"`, `"y"`, `"z"`, `"z."`, `"z+H"`, `"c-H"`, `"i"` (immonium), `"d"`, `"w"`,
  `"v"` and their variants, plus internal types `"by"`, `"ax"`, `"cz"`, and others.
- `CHARGE` = an `int`, a carrier string such as `"Na:z+1"`, or a list of carrier
  strings such as `["Na:z+1", "H:z+1"]`.

`parse` checks syntax only. Modifications are looked up in tacular when you calculate
something. Pass `validate=True` to check them during parsing.

```python
import peptacular as pt

a = pt.parse("[Acetyl]-PEM[Oxidation]TIDE/2")
print(a.stripped_sequence, a.charge_state)   # PEMTIDE 2
print(a.nterm_mods)                          # Mods@Nterm([Acetyl])
print(a.internal_mods)                       # {2: Mods(mod_type=<ModType.INTERNAL: 'internal'>, _mods={'Oxidation': 1})}
print(a.serialize())                         # [Acetyl]-PEM[Oxidation]TIDE/2

print(pt.validate("PEP[Foo]TIDE"))           # False
try:
    pt.parse("PEP[Foo]TIDE", validate=True)
except pt.PeptacularError as e:
    print(type(e).__name__)                  # ProFormaFormatError

print(pt.parse_chimeric("PEPTIDE/2+ELVIS/3"))  # [ProFormaAnnot(sequence=PEPTIDE, charge=2), ProFormaAnnot(sequence=ELVIS, charge=3)]
```

## ProFormaAnnotation

```
ProFormaAnnotation(sequence: str | None = None, compound_name=None, ion_name=None, peptide_name=None,
                   isotope_mods=None, static_mods=None, labile_mods=None, unknown_mods=None,
                   nterm_mods=None, cterm_mods=None, internal_mods: dict[int, Any] | None = None,
                   intervals: list[Interval] | None = None, charge=None, validate: bool = False)
```

The class has about 200 methods. They follow a regular naming scheme, so learn the
pattern:

- **Modification types** (`ModType` / string): `nterm`, `cterm`, `internal` (per index),
  `static` (fixed `<...@C>`), `isotope` (`<13C>`), `labile` (`{...}`), `unknown` (`[...]?`),
  `interval`, `charge`.
- **Per type**: each type has `<type>_mods` (property), `has_<type>_mods`,
  `set_<type>_mods`, `append_<type>_mod`, `extend_<type>_mods`, `remove_<type>_mod`,
  `pop_<type>_mods`, `clear_<type>_mods`, `validate_<type>_mods`, and `<type>_mods_str`.
- **Internal mods** are indexed by residue: `set_internal_mods_at_index(i, mods)`,
  `append_internal_mod_at_index(i, mod)`, `get_internal_mods_at_index(i)`,
  `pop_internal_mod_at_index(i)`, `clear_internal_mod_at_index(i)`.
- **Mod values** can be a name (`"Oxidation"`), an accession (`"UNIMOD:35"`), a number
  (`15.9949`), a ProForma tag string, or a `{mod: count}` dict.
- **In place by default.** Every mutator takes `inplace: bool = True` and returns the
  annotation. That is itself, or a copy when `inplace=False`, so calls chain.
- **Other state**: `sequence`, `stripped_sequence`, `charge`, `charge_state`,
  `charge_adducts`, `peptide_name`, `ion_name`, `compound_name`, `set_charge`,
  `set_peptide_name`, `set_sequence`, `copy()`, `has_mods`, `strip_mods`, `update`.
- **Calculations**: `mass`, `neutral_mass`, `mz`, `comp`, `fragment`, `frag`,
  `fast_fragment`, `isotopic_distribution`, `estimate_isotopic_distribution`, `prop`.
- **Slicing**: `annot[i:j]`, `annot[span]`, `slice`, `slice_by_span`,
  `sliding_windows`. Mods travel with their residues.
- **Digestion**: `digest_spans(enzyme, missed_cleavages=0, semi=False, min_len=None,
  max_len=None)` yields `Span`s. Also `simple_digest_spans`, `semi_spans`,
  `nonspecific_spans`, `cleavage_sites`, `sequential_digest_spans`. Every method that
  yields spans ends in `_spans`.
- **Conversion**: `to_dict` / `from_dict`, `to_json` / `from_json`, `to_ms2_pip` /
  `from_ms2_pip`, `to_diann` / `from_diann`, `to_casanovo` / `from_casanovo`,
  `to_ip2` / `from_ip2_sequence`.

```python
import peptacular as pt

annot = pt.ProFormaAnnotation(sequence="PEPTIDE")
annot.set_nterm_mods({"Acetyl": 1})
annot.set_internal_mods_at_index(2, {"Oxidation": 1})
annot.set_charge(2)
print(annot.serialize())                               # [Acetyl]-PEP[Oxidation]TIDE/2

base = pt.parse("PEPTIDE")
copy = base.set_charge(3, inplace=False)
print(base.serialize(), copy.serialize())             # PEPTIDE PEPTIDE/3

b = pt.parse("PEM[Oxidation]TIDEK")
print(b[1:4].serialize())                             # EM[Oxidation]T
print(b.has_internal_mods, b.strip_mods().serialize())  # True PEMTIDEK
```

## Mass, m/z, composition

```
mass(sequence: SEQS, ion_type: ION = "p", charge: CHARGE | None = None, monoisotopic: bool = True,
     isotopes: int | dict[str | ElementInfo, int] | None = None,
     deltas: str | ChargedFormula | float | dict[..., int] | None = None,
     calculate_with_composition: bool = False, *PAR) -> float | list[float]
mz(...same as mass...) -> float | list[float]
comp(sequence: SEQS, ion_type: ION = "p", charge=None, isotopes=None, deltas=None, *PAR)
     -> Counter[ElementInfo] | list[Counter[ElementInfo]]
ProFormaAnnotation.neutral_mass(ion_type="p", monoisotopic=True, *, isotopes=None, deltas=None, ...) -> float
```

- `charge=None` uses the annotation's own charge. If it has none, the result is the
  neutral mass.
- `isotopes=1` adds one 13C-12C spacing. A dict such as `{"13C": 2}` replaces specific
  isotopes.
- `deltas` adds or removes a formula or a mass, for example `{"H-2O-1": 1}` for a water
  loss or `-17.0265`.
- `ion_type="y"` or `"b"` gives the mass of the whole sequence as that fragment type.

```python
import peptacular as pt

p = pt.parse("PEPTIDE/2")
print(p.mass(), p.neutral_mass(), p.mz())         # 801.3745169660718 799.3599640328299 400.6872584830359
print(pt.mass("PEPTIDE", charge=2))               # 801.3745169660718
print(pt.mass("PEPTIDE", monoisotopic=False))     # 799.8238767988298
print(pt.mass("PEPTIDE", ion_type="y", charge=1)) # 800.367240499451
print(pt.mz("PEPTIDE", charge="Na:z+1"))          # 822.3491847349209
print(round(pt.mass("PEPTIDE", deltas={"H-2O-1": 1}), 4))  # 781.3494
print(round(pt.mass("PEPTIDE", isotopes=1), 4))   # 800.3633

print(pt.chem_formula(pt.comp("PEPTIDE")))        # C34H53N7O15
print(pt.mass("PEP[+15.995]TIDE"))                # 815.3549640328299
try:
    pt.comp("PEP[+15.995]TIDE")
except pt.CompositionError as e:
    print(e)                                      # Cannot calculate composition with delta mass changes. Use mass() or mz() instead.
```

Chemistry helpers work on formulas, not peptides:

```
chem_mass(formula: str | Mapping[ElementInfo | str, int] | Sequence[...], monoisotopic: bool = True, *PAR) -> float | list[float]
chem_comp(formula, *PAR) -> Counter[ElementInfo] | list[...]
chem_formula(comp, hill_order: bool = True, sep: str = "", include_formula_prefix: bool = False, *PAR) -> str | list[str]
parse_formula(formula, sep: str = "", *PAR) -> Counter[ElementInfo] | list[...]
add_composition(total: Counter[ElementInfo], other: Mapping[ElementInfo, int]) -> None   # in place
merge_compositions(components: Iterable[HasMassComp]) -> Counter[ElementInfo]
```

```python
import peptacular as pt

print(pt.chem_mass("C2H4O2"))                     # 60.02112936806
print(pt.chem_formula(pt.chem_comp("H2O")))       # H2O
```

## Fragments

```
fragment(sequence: SEQS, ion_types: Sequence[ION] = ("b", "y"), charges: Sequence[CHARGE] | None = None,
         monoisotopic: bool = True, isotopes: Sequence[int | dict | None] = (0,),
         deltas: Sequence[... | None] = (None,), neutral_deltas: Sequence[NeutralDelta | str | None] = (),
         calculate_with_composition: bool = False, max_ndeltas: int = 1, *PAR) -> list[Fragment] | list[list[Fragment]]
frag(...)            # annotation method: one Fragment for the whole sequence as ion_type
fast_fragment(sequence: SEQS, ion_types=("b", "y"), charges: Sequence[int] | None = None,
              monoisotopic: bool = True, *PAR) -> dict[tuple[IonType, int], list[float]] | list[dict]
```

`fragment` returns the full product of ion types, positions, charges, isotopes and
deltas. `charges=None` uses the peptide's own charge, or 1 if it has none.
`neutral_deltas` names tacular neutral losses (`"H2O"`, `"NH3"`, `"H3PO4"`, ...).

`Fragment` fields: `ion_type`, `position` (an int, or a `(start, end)` tuple for internal
ions), `mass`, `monoisotopic`, `charge_state`, `charge_adducts`, `isotopes`, `deltas`,
`composition`, `parent_sequence`. Properties: `mz`, `neutral_mass`, `sequence`,
`deltas` (neutral losses/gains as `{ChargedFormula or mass: count}`), `is_c13`,
`is_protonated`. Methods: `to_mzpaf(*, include_sequence=True)` returns an mzPAF string that
`paftacular` can parse, `asdict()`, and `replace(**changes)` (constructor names) for a
changed copy. Fragments are immutable and compare/hash by value.

`fast_fragment` skips `Fragment` objects and returns m/z lists keyed by
`(IonType, charge)`. It returns the same ions as `fragment` (b1..bn, y1..yn) and agrees with it to within 1e-9 Da.

```python
import peptacular as pt

frags = pt.fragment("PEPTIDE", ion_types=("b", "y"), charges=[1])
print(len(frags), frags[0])                       # 14 Fragment(ion_type=b, position=1, mass=98.0600, charge=1)
print(frags[1].to_mzpaf())                        # b2{PE}

peptide = pt.parse("PEM[Oxidation]TIDE/2")
for f in peptide.fragment(ion_types=["b", "y"], charges=[1, 2])[:3]:
    print(f"{f.ion_type}{f.position}+{f.charge_state}: {f.mz:.3f}")
# b1+1: 98.060
# b2+1: 227.103
# b3+1: 374.138

fast = pt.fast_fragment("PEPTIDE", ion_types=("b",), charges=[1])
print([round(x, 3) for x in fast[(pt.IonType.B, 1)]][:3])  # [98.06, 227.103, 324.155]
```

## Localization isomers

```
localization_isomers(peptide: str | ProFormaAnnotation | HasSequence, *, max_isomers: int | None = 10_000) -> list[ProFormaAnnotation]
candidate_sites(peptide, mod: str, *, residues: str) -> list[tuple[int, ProFormaAnnotation]]
site_determining_ions(isomers: Sequence[str | ProFormaAnnotation], *, ion_types=("b", "y"), charges=(1,),
                      tolerance: float | None = None, tolerance_unit: ToleranceUnit = "da") -> list[list[Fragment]]
pairwise_site_determining_ions(isomers, *, same keywords) -> dict[tuple[int, int], list[Fragment]]
annotation.localization_isomers(*, max_isomers=10_000); annotation.candidate_sites(mod, *, residues)
```

`localization_isomers` places every ambiguous mod: unknown-position mods (`[Phospho]?`,
`[Phospho]^2?`; with no range they may go on ANY residue), ranges (`(ST)[Phospho]`) and
`#label` groups (`S[Phospho#g1(0.8)]T[#g1(0.2)]`). Candidate sites come from the ProForma
string only; there is no built-in residue list. A group's label and the score of the chosen
residue stay on the placed mod (`S[Phospho#g1(0.8)]`). Order is fixed (groups, ranges,
unknown mods; N- to C-terminal), duplicates are dropped, and `max_isomers` (default `pt.DEFAULT_MAX_ISOMERS` = 10,000;
None = no limit) raises `PeptacularError` when exceeded. One mod per residue: a placed mod
never goes on a residue that already has one or that another ambiguity used, so
`[Phospho]?PES[Phospho]T` never gives `S[Phospho][Phospho]`; a `#label` group listing a residue
that already carries another mod raises `PeptacularError`. A `#` inside `INFO:` text is not
a group label. `#XL`/`#BRANCH` labels are left alone.
`candidate_sites` needs `residues` (no default) and adds one copy of the mod on each
unmodified matching residue. `site_determining_ions` returns, per isomer, the Fragments
whose m/z matches no ion of any other isomer within `tolerance` (None: 1e-6 Da; window
edges match, via `tacular.tolerance_window`). With 3+ adjacent sites the middle isomers get
`[]`; `pairwise_site_determining_ions` gives `{(i, j): ions of i that j cannot explain}` for
every ordered pair (the Ascore/PhosphoRS comparison).

```python
import peptacular as pt

isomers = pt.localization_isomers("PEPS[Phospho#g1(0.8)]T[#g1(0.2)]IDE")
print([a.serialize() for a in isomers])  # ['PEPS[Phospho#g1(0.8)]TIDE', 'PEPST[Phospho#g1(0.2)]IDE']
print([p for p, _ in pt.candidate_sites("PEPSTYK", "Phospho", residues="STY")])  # [3, 4, 5]
```

## Digestion

```
digest(sequence: SEQS, enzyme: str | re.Pattern, missed_cleavages: int = 0, semi: bool = False,
       min_len: int | None = None, max_len: int | None = None, *, *PAR) -> list[tuple[str, Span]] | list[list[...]]
simple_digest(sequence: SEQS, cleave_on: str, restrict_before: str = "", restrict_after: str = "",
              cterminal: bool = True, missed_cleavages: int = 0, semi: bool = False,
              min_len=None, max_len=None, *, *PAR) -> list[tuple[str, Span]] | ...
semi_digest / left_semi_digest / right_semi_digest / nonspecific_digest
             (sequence: SEQS, min_len=None, max_len=None, *PAR) -> list[tuple[str, Span]] | ...
cleavage_sites(sequence: SEQS, enzyme: str | re.Pattern, *PAR) -> list[int] | list[list[int]]
simple_cleavage_sites(sequence: SEQS, cleave_on: str, restrict_before="", restrict_after="", cterminal=True, *PAR)
span_to_sequence(sequence: SEQS, span: Span | tuple[int, int, int], *PAR) -> str | list[str]
Span(start: int, end: int, missed_cleavages: int)                         # NamedTuple, end exclusive
EnzymeConfig(enzyme: str | re.Pattern, *, missed_cleavages: int = 0, semi: bool = False, complete_digestion: bool = True)  # frozen
build_spans / build_enzymatic_spans(max_index, enzyme_sites, missed_cleavages, min_len=None, max_len=None, semi=False)
build_semi_spans / build_left_semi_spans / build_right_semi_spans / build_non_enzymatic_spans(span, min_len=None, max_len=None)
calculate_span_coverage(spans, max_index: int, accumulate: bool = False) -> list[int]
```

`enzyme` takes a tacular protease name (`"trypsin"`), a `pt.Protease` member, or a
compiled pattern (`re.compile("(?<=[KR])")`). A string is never used as a regex: an
unknown name raises `pt.UnknownEnzymeError` (a `PeptacularError` and a `KeyError`).
`"unspecific"` cuts at every position. Modified residues keep their mods in the
returned strings.

```python
import peptacular as pt

print(pt.digest("MKVLATSAGERTIDEK", enzyme="trypsin"))
# [('MK', Span(start=0, end=2, missed_cleavages=0)), ('VLATSAGER', Span(start=2, end=11, missed_cleavages=0)), ('TIDEK', Span(start=11, end=16, missed_cleavages=0))]

peps = pt.digest("MKVLATSAGERTIDEK", enzyme="trypsin", missed_cleavages=1)
print([s for s, _ in peps])          # ['MK', 'MKVLATSAGER', 'VLATSAGER', 'VLATSAGERTIDEK', 'TIDEK']

print([s for s, _ in pt.simple_digest("MKVLATSAGERTIDEK", cleave_on="KR")])  # ['MK', 'VLATSAGER', 'TIDEK']
print(pt.cleavage_sites("MKVLATSAGERTIDEK", enzyme="trypsin"))           # [2, 11]

protein = pt.parse("PEM[Oxidation]TREPTIDEK")
for span in protein.digest_spans(pt.Protease.TRYPSIN):
    print(span, protein[span].serialize())
# Span(start=0, end=5, missed_cleavages=0) PEM[Oxidation]TR
# Span(start=5, end=12, missed_cleavages=0) EPTIDEK
```

## Tables (records for pandas or polars)

```
digest_records(sequence: SEQ | Iterable[SEQ], enzyme: str | re.Pattern, *, missed_cleavages: int = 0, semi: bool = False,
               min_len: int | None = None, max_len: int | None = None) -> list[dict]
fragment_records(fragments: Iterable[Fragment] | Iterable[Iterable[Fragment]]) -> list[dict]
fragment_arrays(sequence: SEQS, ion_types=("b", "y"), charges=None, *, monoisotopic=True, isotopes=(0,),
                deltas=(None,), neutral_deltas=(), calculate_with_composition=False, max_ndeltas=1,
                min_length=None, max_length=None, *PAR) -> dict[str, numpy.ndarray]   # extra: peptacular[numpy]
DIGEST_RECORD_KEYS = ("peptide", "stripped_sequence", "start", "end", "missed_cleavages", "semi", "accession")
FRAGMENT_RECORD_KEYS = ("ion_type", "position", "end_position", "charge_state", "mz", "mass", "neutral_mass",
                        "monoisotopic", "deltas", "isotopes", "sequence", "parent_sequence", "mzpaf")
FRAGMENT_ARRAY_KEYS = ("peptide_index", "ion_type", "position", "end_position", "charge_state", "mz", "mass",
                       "isotope", "isotope_label", "delta_label", "delta_mass")
```

peptacular does NOT depend on pandas or polars (not even optionally); pass the rows to
`pandas.DataFrame(rows)` / `polars.DataFrame(rows)` yourself. Digest rows: `start`/`end` are
half-open, `semi` is True when an end is not a cleavage site or terminus (only with
`semi=True`), `accession` comes from the input's `.accession` (FASTA entry), else `.db_unique_id` (PEFF
entry), else None; a list or generator of proteins gives one flat list. Fragment rows use the
`Fragment` constructor names and take one peptide's list or the nested batch list (flattened);
`position` is the ion number or an internal ion's start, `end_position` the internal ion's end
(both int or None); `deltas`/`isotopes` are strings. Each delta is a signed formula or mass
added `count` times; named losses such as H2O are stored as negative formulas (water loss
`"H-2O-1"`, water gain `"H-2O-1^-1"`), a plain formula like `"C2H2O"` is a gain, a mass keeps
its sign (`"-17.0^2"`). `sequence`/`parent_sequence` have no `/charge`; `mzpaf` is None for an
ion type mzPAF cannot write or a mixed-sign formula delta (`"CH-2"`). A non-Fragment item raises
PeptacularError; a wrong top-level type (None, a number) raises TypeError, as `pt.digest` does.

```python
import peptacular as pt

rows = pt.digest_records("MKVLATSAGERTIDEK", "trypsin", missed_cleavages=1)
print(rows[1]["peptide"], rows[1]["start"], rows[1]["end"], rows[1]["missed_cleavages"])  # MKVLATSAGER 0 11 1
ions = pt.fragment_records(pt.fragment("PEPTIDE", ion_types=("y",), charges=(1,)))
print([round(r["mz"], 3) for r in ions][:3])  # [148.06, 263.087, 376.171]
```

`fragment_arrays` needs numpy (`pip install "peptacular[numpy]"`; without it raises
`MissingOptionalDependencyError` naming that command). Same arguments as `fragment` plus `min_length`/`max_length` (as on
`ProFormaAnnotation.fragment`), same ions in
the same order with identical floats, returned as a dict of equal-length numpy arrays (keys
`FRAGMENT_ARRAY_KEYS`), one row per ion; `peptide_index` is the input peptide's index (0 for one
sequence). int64: `peptide_index`, `position`, `end_position`, `charge_state`, `isotope` (13C count);
a missing position is 0, not None. float64: `mz`, `mass` (charged), `delta_mass` (total delta mass,
negative for a loss). object str: `ion_type`, `isotope_label`, `delta_label` (the `isotopes`/`deltas`
format of `fragment_records`). `pl.DataFrame(cols)` / `pa.table(cols)` accept it as is. About 10x
faster than `fragment()` + building a table; plain a/b/c/x/y/z series use numpy prefix sums, other
ions go through `fragment()`. A list runs in one process unless `n_workers=`/`method=` is given.

```python
cols = pt.fragment_arrays(["PEPTIDE/2", "PEM[Oxidation]K"], ion_types=("b", "y"), charges=(1, 2))
print(cols["peptide_index"][:3].tolist(), cols["mz"][:3].round(4).tolist())  # [0, 0, 0] [98.06, 227.1026, 324.1554]
```

## Isotopic envelopes

```
isotopic_distribution(sequence: SEQS, ion_type: ION = "p", charge=None, isotopes=None, deltas=None,
                      max_isotopes: int | None = None, min_abundance_threshold: float = 0.001, *PAR)
                      -> list[IsotopicData] | list[list[IsotopicData]]
brain_isotopic_distribution(formula: Mapping[str | ElementInfo, int | float], *, max_isotopes=None,
                            min_abundance_threshold: float = 0.001, charge: int | None = None) -> list[IsotopicData]
estimate_isotopic_distribution(neutral_mass: float, *, max_isotopes=None, min_abundance_threshold=0.001) -> list[IsotopicData]
merge_isotopic_distributions(*distributions: list[IsotopicData], merge_precision: int | None = None) -> list[IsotopicData]
averagine_comp(neutral_mass: float) -> Counter[ElementInfo]              # nearest integer averagine
estimate_averagine_comp(neutral_mass: float, ion_type: ION = "p") -> Mapping[ElementInfo, float]
IsotopicData(mass: float, neutron_count: int, abundance: float)          # abundance relative to the max peak
```

This uses the BRAIN algorithm. The result has one aggregated peak per nominal neutron
offset, at its exact probability-weighted center mass. If `max_isotopes` is omitted,
the envelope is sized adaptively down to `min_abundance_threshold`. Modifications
without a composition (delta masses) cannot be used; use
`estimate_isotopic_distribution(mass)` for those.

```python
import peptacular as pt

for peak in pt.isotopic_distribution("PEPTIDE", max_isotopes=3):
    print(peak.neutron_count, round(peak.mass, 4), round(peak.abundance, 4))
# 0 799.36 1.0
# 1 800.363 0.4051
# 2 801.3655 0.1108

print(pt.estimate_isotopic_distribution(1000.0, max_isotopes=2)[1].neutron_count)  # 1
```

## Modifications on strings (functional)

```
modify(sequence, *, nterm_static=None, cterm_static=None, internal_static=None, labile_static=None,
       nterm_variable=None, cterm_variable=None, internal_variable=None, labile_variable=None,
       max_variable_mods: int = 2, use_regex: bool = False, unique_peptidoforms: bool = False, *PAR)
       -> list[str] | list[list[str]]          # mappings are {residue_or_None: [mods]}
set_mods / append_mods / extend_mods(sequence: str | ProFormaAnnotation, mods: Mapping[ModType | str | int, Any]) -> str
pop_mods(sequence, mods: ModType | Iterable[ModType] | None = None) -> tuple[str, dict[ModType, Any]]
remove_mods / filter_mods(sequence, mods=None) -> str       # remove these types / keep only these types
strip_mods(sequence: SEQS, mods=None) -> str | list[str]     # bare residues
condense_static_mods(sequence) -> str                        # apply <...@X> to residues
condense_to_peptidoform(sequence: SEQS) -> str | list[str]
is_modified(sequence: SEQS, *PAR) -> bool | list[bool]
get_mods(mods) -> list[ModType]; get_mod_type(mod) -> ModType
```

```python
import peptacular as pt

print(pt.strip_mods("[Acetyl]-PEM[Oxidation]TIDE/2"))   # PEMTIDE
print(pt.is_modified(["PEPTIDE", "PEM[Oxidation]TIDE"]))  # [False, True]
print(pt.modify("PEPTIDEMC", internal_static={"C": ["Carbamidomethyl"]}, internal_variable={"M": ["Oxidation"]}))
# ['PEPTIDEMC[Carbamidomethyl]', 'PEPTIDEM[Oxidation]C[Carbamidomethyl]']
print(pt.condense_static_mods("<[Carbamidomethyl]@C>PEPTCIDE"))  # PEPTC[Carbamidomethyl]IDE
```

## Sequence utilities

```
sequence_length(sequence: SEQS, *PAR) -> int | list[int]
count_residues(sequence: SEQS, include_mods: bool = True, *PAR) -> dict[str, int] | list[...]
percent_residues(sequence: SEQS, include_mods: bool = True, *PAR) -> dict[str, float] | list[...]
is_ambiguous(sequence: SEQS, *PAR) -> bool | list[bool]
reverse(sequence: SEQS, keep_nterm: int = 0, keep_cterm: int = 0, *PAR) -> str | list[str]
shift(sequence: SEQS, n: int, keep_nterm: int = 0, keep_cterm: int = 0, *PAR) -> str | list[str]
shuffle(sequence: SEQS, seed: int | None = None, keep_nterm: int = 0, keep_cterm: int = 0, *PAR) -> str | list[str]
sort(sequence: SEQS, key=None, reverse: bool = False, *PAR) -> str | list[str]
permutations / combinations / combinations_with_replacement(sequence: SEQS, size: int | None, *PAR) -> list[str] | ...
product(sequence: SEQS, repeat: int | None, *PAR) -> list[str] | ...
coverage(sequence, subsequences, accumulate=False, ignore_mods=False, ignore_ambiguity=False) -> list[int]
percent_coverage(sequence, subsequences, ignore_mods=False, accumulate=False, ignore_ambiguity=False) -> float
modification_coverage(sequence, subsequences, accumulate=False) -> dict[int, int]
find_subsequence_indices(sequence, subsequence, ignore_mods: bool = False) -> list[int]
is_subsequence(subsequence, sequence, order: bool = True, ignore_mods: bool = False) -> bool
annotate_ambiguity(sequence, forward_coverage: list[int], reverse_coverage: list[int], mass_shift=None, ...) -> str
generate_random(count: int | None = None, min_length=6, max_length=20, mod_probability=0.05, ..., require_composition=True, *PAR)
               -> ProFormaAnnotation | list[ProFormaAnnotation]
```

```python
import peptacular as pt

print(pt.sequence_length("[Acetyl]-PEPTIDE"))             # 7
print(pt.count_residues("PEPTIDE"))                       # {'P': 2, 'E': 2, 'T': 1, 'I': 1, 'D': 1}
print(pt.reverse("PEPTIDEK", keep_cterm=1))               # EDITPEPK
print(pt.coverage("PEPTIDEK", ["PEP", "DEK"]))            # [1, 1, 1, 0, 0, 1, 1, 1]
print(pt.percent_coverage("PEPTIDEK", ["PEP"]))           # 0.375
print(pt.find_subsequence_indices("PEPTIDEPEP", "PEP"))   # [0, 7]
```

## Physicochemical properties

These use only the residue sequence. Modifications are ignored.

```
hydrophobicity / hydrophilicity / flexibility / hplc / polarity / mutability / bulkiness / refractivity /
recognition_factors / transmembrane_tendency / surface_accessibility / average_buried_area / codons
    (sequence: SEQS, *PAR) -> float | list[float]            # each uses a default scale
pi(sequence: SEQS, *PAR) -> float | list[float]              # isoelectric point
charge_at_ph(sequence: SEQS, pH: float = 7.0, *PAR) -> float | list[float]
aromaticity(sequence: SEQS, aromatic_residues: list[str] | None = None, *PAR) -> float | list[float]
aa_property_percentage(sequence: SEQS, residues: list[str], *PAR) -> float | list[float]
secondary_structure(sequence: SEQS, scale=SecondaryStructureMethod.DELEAGE_ROUX, *PAR) -> dict[str, float] | list[...]
alpha_helix_percent / beta_sheet_percent / beta_turn_percent / coil_percent(sequence: SEQS, *PAR)
calc_property(sequence: SEQS, scale: str | dict[str, float], missing_aa_handling="error",
              aggregation_method="avg", normalize=False, weighting_scheme=..., ...) -> float | list[float]
calc_window_property(sequence, scale, window_size: int = 9, ...) -> list[float]
property_partitions(sequence: SEQS, scale, num_windows: int = 5, aa_overlap: int = 0, ...) -> list[float] | ...
```

Scale enums (their values are valid `scale=` strings): `HydrophobicityScale`,
`HPLCScale`, `ChargeScale`, `PolarityScale`, `SurfaceAccessibilityScale`,
`SecondaryStructureScale`, `BetaStrandScale`, `CompositionScale`,
`PhysicalPropertyScale`. Option enums: `MissingAAHandling` (`zero`, `avg`, `min`, `max`,
`median`, `error`, `skip`), `AggregationMethod` (`sum`, `avg`), `WeightingMethods`,
`SecondaryStructureMethod`, `SecondaryStructureType`.

The same calculations are available on an annotation as `annot.prop.<name>`. They are
properties for the simple scales and methods for the ones that take arguments.

```python
import peptacular as pt

print(pt.hydrophobicity(["PEPTIDE", "KKK"]))         # [0.34285714285714286, 0.06666666666666668]
p = pt.parse("PEPTIDE").prop
print(round(p.pi, 3), p.aromaticity)                 # 2.87 0.0
ss = p.secondary_structure()
print(round(ss["alpha_helix"], 3))                   # 0.275
print(round(pt.calc_property("PEPTIDE", scale=pt.HydrophobicityScale.KYTE_DOOLITTLE), 3))  # -1.414
```

`secondary_structure` returns fractions from 0 to 1, not percentages.

## Format converters

```
convert_ip2_sequence(sequence: str | list[str], *PAR) -> str | list[str]        # 'K.PEPTIDE.K' -> ProForma
convert_diann_sequence(sequence: str | list[str], *PAR) -> str | list[str]      # '_PEM[...]TIDE_'
convert_casanovo_sequence(sequence: str | list[str], *PAR) -> str | list[str]   # '+43.006PEPTIDE'
to_ms2_pip(sequence: SEQS, *PAR) -> tuple[str, str] | list[tuple[str, str]]     # (sequence, 'pos|Name|...')
from_ms2_pip(sequence: tuple[str, str] | Sequence[tuple[str, str]], static_mods: Mapping[str, float] | None = None, *PAR)
```

```python
import peptacular as pt

print(pt.convert_ip2_sequence("K.PEPTIDE.K"))              # PEPTIDE
print(pt.convert_diann_sequence("_PEM[Carbamidomethyl]TIDE_"))  # PEM[Carbamidomethyl]TIDE
print(pt.convert_casanovo_sequence("+43.006PEPTIDE"))      # [+43.006]-PEPTIDE
print(pt.to_ms2_pip("PEM[Oxidation]TIDE"))                 # ('PEMTIDE', '3|Oxidation')
```

## FASTA input

peptacular does not read files (5.0 removed `parse_fasta`, `parse_fasta_text`,
`iter_fasta`, `FastaSequence` and `FastaFormatError`). Read FASTA with fastatacular
(`pip install fastatacular`) or PEFF with pefftacular. Every sequence function, and
`batch` / `iter_batch` / `diagnose`, accepts any object with a `sequence` str attribute
(the `pt.HasSequence` protocol), so entries go straight in:

```
HasSequence                                               # Protocol: .sequence -> str, read as ProForma
```

```python
import io
from fastatacular import FastaReader, read_fasta
import peptacular as pt

with FastaReader(io.StringIO(">a\nPEPTIDE\n>b\nMKR\n")) as entries:
    for entry in entries:
        print(entry.identifier, round(pt.mass(entry), 3))
# a 799.36
# b 433.247
```

fastatacular keeps residue case and raises on an entry with no sequence; the old
`pt.parse_fasta` uppercased and silently skipped empty entries.

## Batch processing with per-item errors

```
batch(operation, sequences: Iterable[str | ProFormaAnnotation | HasSequence], *, errors: "raise" | "collect" = "raise",
      batch_size: int = 1000, n_workers=None, chunksize=None, method=None, start_method=None, **kwargs) -> list[BatchResult]
iter_batch(...same...) -> Iterator[BatchResult]              # yields results lazily, batch_size at a time
diagnose(sequence, operation="mass", **kwargs) -> Diagnostic | None
BatchResult(index: int, input, value=None, error: Diagnostic | None = None)   # .ok
Diagnostic(code: str, stage: "parse" | "validate" | "calculate", message: str, exception_type: str)
BatchOperation = Literal["parse", "mass", "mz", "comp", "fragment", "fast_fragment", "digest", "isotopic_distribution"]
```

`**kwargs` go to the operation, for example `charge=2` for `mz`. With
`errors="collect"`, expected input errors become `Diagnostic`s and the rest still
raise. Expected errors are invalid notation, unresolved mods and unavailable
compositions. Plain `pt.mass(list)` raises on the first bad item.

```python
import peptacular as pt

results = pt.batch("mass", ["PEPTIDE", "PEP[UnknownModification]TIDE", "PEP[TIDE"], errors="collect")
for r in results:
    print(r.index, r.ok, round(r.value, 3) if r.ok else r.error.code)
# 0 True 799.36
# 1 False unresolved_modification
# 2 False invalid_notation

print(pt.diagnose("PEPTIDE"))                        # None
```

## Errors

All of these subclass `PeptacularError` (4.2), which subclasses `ValueError`, so
`except ValueError` still catches them:

- `ProFormaFormatError`: the string is not valid ProForma (`parse`, `parse_chimeric`, and lazily parsed mods, glycans, isotope labels and adducts inside `mass()` etc.). The message includes the position and a caret.
- `UnknownModificationError`: a modification name or accession does not resolve.
- `CompositionError`: a composition was requested for delta-mass mods, or the sequence is empty (`pt.mass("")`).
- `InvalidAdjustmentError`: bad isotope or delta counts.
- `InvalidPositionError` (4.2): a slice index or `frag(position=...)` is outside the sequence.
- `UnsupportedOperationError`: the operation does not support this input, e.g. an unknown ion type (`fragment(ion_types=["q"])`; the message lists valid types).
- `UnknownEnzymeError` (5.0): an `enzyme` string names no protease in `PROTEASE_LOOKUP` (a typo such as `"trypsn"`, or a regex passed as a string; pass `re.compile(...)` for a custom rule). Also a `KeyError`.

Other gotchas: `pt.shift(seq, n)` raises `TypeError` for a non-integer `n`.

```python
import peptacular as pt

try:
    pt.mass("PEP[Foo]TIDE")
except pt.UnknownModificationError as e:
    print(str(e).split(":")[0])                      # Unknown modification name 'Foo'
try:
    pt.parse("PEP[TIDE")
except pt.ProFormaFormatError as e:
    print(str(e).splitlines()[0])                    # Unclosed '[': reached end of sequence before finding a matching ']'
```

## JSON interchange

```
to_proforma_json(value, *, indent: int | None = None) -> str       # deterministic, lossless
from_proforma_json(data: str | bytes | bytearray, expected_type: type | None = None) -> Any
to_proforma_dict(value) -> dict[str, Any]
from_proforma_dict(data: Mapping[str, Any], expected_type: type | None = None) -> Any
get_proforma_json_schema() -> dict[str, Any]                         # JSON Schema (draft 2020-12)
PROFORMA_JSON_SCHEMA_ID = "https://peptacular.readthedocs.io/en/latest/proforma-json-v1.schema.json"
PROFORMA_JSON_SCHEMA_VERSION = "1.0"
```

This works for `ProFormaAnnotation` and for the `proforma_components` dataclasses. The
annotation also has `to_json()` / `from_json()` and `to_dict()` / `from_dict()`.

```python
import peptacular as pt

s = pt.to_proforma_json(pt.parse("PEM[Oxidation]TIDE/2"))
print(s[:60])                                        # {"$schema": "https://peptacular.readthedocs.io/en/latest/pro
print(pt.from_proforma_json(s).serialize())          # PEM[Oxidation]TIDE/2
```

## ProForma component classes

These are frozen dataclasses from `peptacular.proforma_components`. Most have
`from_string()`, `serialize()`, `get_mass(*, monoisotopic=True)`, `get_composition()`, and
`to_dict()`/`from_dict()`.

- Modification tags: `ModificationTags(tags)` and the tag types `TagName(name, cv=None)`,
  `TagAccession(accession, cv)`, `TagMass(mass_str, cv=None)`, `TagCustom(name)` and
  `TagInfo(info)`. `CV` is one of `UNIMOD`, `MOD` (PSI-MOD), `RESID`, `GNO`, `XLMOD`,
  `CUSTOM` or `OBSERVED`.
- Formulas: `ChargedFormula(formula, charge=None)` and
  `FormulaElement(element, occurance, isotope=None)`.
- Glycans: `GlycanTag(components)` and `GlycanComponent(monosaccharide, occurance)`.
- Charge: `GlobalChargeCarrier(charged_formula, occurance)`.
- Global rules: `IsotopeReplacement(element, isotope)`,
  `FixedModification(modifications, position_rules)`,
  `PositionRule(terminal, amino_acid=None)`, `Terminal`.
- Ambiguity and cross-links: `ModificationAmbiguousPrimary`,
  `ModificationAmbiguousSecondary`, `ModificationCrossLinker`, `PositionTag`,
  `PositionScore`, `LimitTag`.
- Whole structures: `SequenceElement`, `SequenceRegion`, `Peptidoform`,
  `PeptidoformIon`, `CompoundPeptidoformIon`.

`occurance` is the field name used in the code.

```python
import peptacular as pt

cf = pt.ChargedFormula.from_string("Formula:HO3P")    # the "Formula:" prefix is required
print(round(cf.get_mass(), 4), cf.serialize())       # 79.9663 Formula:HO3P
```

## Parallelism

- Every functional-API call accepts the keyword-only arguments `n_workers`, `chunksize`
  and `method` (`"process"`, `"thread"` or `"sequential"`).
- With no arguments, a list shorter than 1000 items runs sequentially, and a longer list
  uses a process pool.
- `set_start_method("spawn" | "fork" | "forkserver")`, `get_start_method()` and
  `get_available_start_methods()` control the multiprocessing context.
- On Linux before Python 3.14 the default is `fork`. `fork` warns if the process already
  has threads.
- Scripts that create process pools need an `if __name__ == "__main__":` guard on
  spawn platforms (Windows and macOS).

## Optional integrations

`peptacular.interop` is not star-imported. It needs the matching extra.

```
from peptacular.interop import (
    to_pyteomics, from_pyteomics,                     # pyteomics.proforma objects
    to_pyteomics_composition, from_pyteomics_composition,
    to_psm_utils, from_psm_utils,                     # psm_utils.Peptidoform
    to_alphabase_row, from_alphabase_row,             # AlphaBase sequence/mods/mod_sites/charge columns
    to_alphabase_dataframe, from_alphabase_dataframe,
    LossPolicy,                                       # "error" | "warn" | "drop" for features the target cannot hold
    InteropError, InteropConversionError, MissingOptionalDependencyError, LossyConversionWarning,
)
```

Guide: https://peptacular.readthedocs.io/en/latest/interoperability.html

## CLI: local MCP server

`pip install "peptacular[mcp]"` installs `peptacular-mcp`, a stateless stdio MCP
server. It has 12 tools:

- `inspect_peptides`, `analyze_peptides`, `fragment_peptides`, `compare_peptides`
- `isotope_envelopes`, `digest_proteins`, `edit_peptides`, `enumerate_modifications`
- `map_peptides`, `convert_annotations`, `get_reference`, `find_modifications`

```bash
peptacular-mcp --check      # JSON: available tools, package versions, request limits
peptacular-mcp              # run the server on stdio (point your MCP client at the absolute path)
```

Each input is limited to 100 records and 1 MiB per request. Client setup:
https://peptacular.readthedocs.io/en/latest/mcp.html

## Constants

- `PROTON_MASS` = 1.007276466621 (CODATA 2018, from `tacular.constants`)
- A monoisotopic proton charge adds `PROTON_MASS` in `mass()`, `mz()`, `fragment()` and
  `fast_fragment()`; average masses add average H minus an electron
- `HYDROGEN_BINDING_MASS` = `PROTON_MASS - (HYDROGEN_MASS - ELECTRON_MASS)` = 1.43e-8, added per
  proton when a charged mass is summed from an elemental composition
- `ELECTRON_MASS` = 0.000548579909065 (CODATA 2018, from `tacular.constants`)
- `NEUTRON_MASS` = 1.00866491595 (CODATA 2018, from `tacular.constants`)
- `C13_NEUTRON_MASS` = 1.00335483507 (`tacular.constants.C13_C12_MASS_DIFF`)
- `PEPTIDE_AVERAGINE_NEUTRON_MASS` = 1.002856
- `AVERAGINE_RATIOS`: elemental ratios per dalton
- `ModType` and its string values: `nterm`, `cterm`, `isotope`, `static`, `labile`,
  `unknown`, `interval`, `internal`, `charge`
- `ParallelMethod`: `process`, `thread`, `sequential`

## Gotchas

1. **`parse` does not resolve modifications.** Unknown names fail later, at
   `mass`/`comp`. Use `validate=True` or `pt.validate` to catch them early.
2. **`mass()` is charged when the sequence has a charge.** For `PEPTIDE/2` it is M + 2
   protons. Use `neutral_mass()` for M and `mz()` for m/z.
3. **Delta-mass mods (`[+15.995]`) have mass but no composition.** `comp`, composition
   isotopes and `calculate_with_composition=True` raise `CompositionError`.
4. **Mutators work in place by default.** Pass `inplace=False` or call `.copy()` to keep
   the original. Because it is mutable, an annotation is not hashable: use
   `annot.serialize()` as a dict key or set member.
5. **Indexes are 0-based and spans are end-exclusive.** `Span(0, 2, 0)` is residues 0
   and 1. MS2PIP output is 1-based, because that is its format.
6. **`fast_fragment` returns a dict of m/z lists**, not `Fragment` objects.
7. **A single `parse` cannot hold `+`-joined chimeric input.** Use `parse_chimeric`.
8. **peptacular does not read FASTA.** Use fastatacular (`read_fasta`, `FastaReader`)
   and pass the entries directly; anything with a `.sequence` str is accepted.
9. **Properties ignore modifications.** `secondary_structure` returns fractions, not
   percentages.
10. **Star imports leak names.** `pt.` also exposes some typing helpers (`pt.Counter`,
    `pt.Literal`, ...) from modules without `__all__`. They are not part of the API.
11. **Component `from_string` parsers take the ProForma tag text.** For example,
    `ChargedFormula.from_string("Formula:HO3P")` needs the `Formula:` prefix. Charge
    carriers are written `Na:z+1`, not `+2Na+`.

## More

- Quick start: https://peptacular.readthedocs.io/en/latest/quickstart.html
- Mass calculation details: https://peptacular.readthedocs.io/en/latest/mass_calculation.html
- Streaming guide: https://peptacular.readthedocs.io/en/latest/streaming.html
- JSON serialization: https://peptacular.readthedocs.io/en/latest/json_serialization.html
- API reference: https://peptacular.readthedocs.io/en/latest/api.html
- Changelog: https://github.com/tacular-omics/peptacular/blob/main/CHANGELOG.md
- mzPAF fragments: https://github.com/tacular-omics/paftacular
- Reference data: https://github.com/tacular-omics/tacular
