From cb86584afe35123cdd0937b5f8cc03d6ae02a429 Mon Sep 17 00:00:00 2001 From: Alex Rubinsteyn Date: Thu, 9 Jul 2026 21:39:08 -0400 Subject: [PATCH] Add ERAMER ERAP1-trimming predictor (fills the erap_trimming gap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ERAP1 trims the N-termini of 9-16mer precursor peptides in the ER down to the 8-10mers MHC-I presents — the processing step between TAP transport and MHC loading, and previously the one empty Kind in mhctools (erap_trimming was "reserved, not yet used"; every other kind now has a predictor). ERAMER (Al-okaily 2024) models ERAP1 specificity as a per-length position-weight matrix and scores a precursor by averaging the PWM specificity over each residue trimmed off as it is cut toward a target epitope length. Licensing: ERAMER is GPLv3 and its PWM ships in a GPL-licensed PWM.xlsx; mhctools is Apache-2.0 and vendors neither. This is a clean-room Python-3 reimplementation of the (Python-2.7) tool's trimming-cascade average that loads the PWM from a user-provided ERAMER checkout at runtime (ERAMER_HOME / eramer_home= / pwm_path=) via openpyxl — exactly how the netMHC / MixMHCpred wrappers read user-provided model files. - mhctools/eramer.py: _find_pwm_path (honors explicit paths, else env/home), load_pwm (openpyxl, lazy import), _specificity + eramer_score (the cascade), and the allele-free ERAMER predictor (predict() / predict_dataframe()). - pred.py: PeptideResult.erap_trimming accessor. - annotate.py: "erap_trimming" output-field token so predict-table can consume ERAMER (--predictor eramer:col:erap_trimming). Verified end-to-end. - cli/args.py + __init__.py: registered/exported "eramer". - tests/test_eramer.py: cascade math tested binary-free with a synthetic PWM; validation/support tests; e2e gated on ERAMER_HOME + openpyxl, reproducing ERAMER v1.0's own sample outputs exactly (0.085322875, 0.273310375). Added to the public CI subset. - README: ERAP1 trimming section + kind_support row; erap_trimming kind is no longer "reserved". Every Kind in the taxonomy now has at least one predictor. Note: ERAMER's evaluation is self-reported and ERAP1 trimming is intrinsically noisy — treat the score as a pathway prior, not a validated oracle. Version 3.26.0 -> 3.27.0. Claude-Session: https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG --- .github/workflows/tests.yml | 1 + README.md | 33 ++++- mhctools/__init__.py | 4 +- mhctools/annotate.py | 1 + mhctools/cli/args.py | 2 + mhctools/eramer.py | 281 ++++++++++++++++++++++++++++++++++++ mhctools/pred.py | 5 + tests/test_eramer.py | 134 +++++++++++++++++ 8 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 mhctools/eramer.py create mode 100644 tests/test_eramer.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ebb60a4..9790a99 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,7 @@ jobs: tests/test_mixmhc2pred2.py \ tests/test_deeptap.py \ tests/test_calis.py \ + tests/test_eramer.py \ tests/test_processing_predictor.py \ tests/test_pepsickle.py \ tests/test_bigmhc.py \ diff --git a/README.md b/README.md index 16f6ff4..dcc8e3e 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ The canonical prediction kind strings are defined in `mhctools.pred.Kind`. | `proteasome_cleavage` | Proteasomal (MHC-I, cytosolic) C-terminal cleavage score | | `endolysosomal_cleavage` | Endolysosomal (MHC-II, cathepsin) C-terminal cleavage score | | `tap_transport` | TAP transport / binding score | -| `erap_trimming` | ERAP trimming score (reserved, not yet used) | +| `erap_trimming` | ERAP1 N-terminal trimming score | Predictors also expose `kind_support()` so downstream code can tell what MHC context is meaningful for each emitted kind: @@ -210,6 +210,7 @@ Examples: | `NetCleave_I` | `proteasome_cleavage` | `none` | `I` | | `NetCleave_II` | `endolysosomal_cleavage` | `none` | `II` | | `DeepTAP` | `tap_transport` | `none` | `none` | +| `ERAMER` | `erap_trimming` | `none` | `I` | | `NetTCR` | `pMHC_TCR_binding` | `none` | `I` | | `Tulip` | `pMHC_TCR_binding` | `single_allele` | `I` | | `BigMHC_IM` | `immunogenicity` | `single_allele` | `I` | @@ -390,6 +391,36 @@ results[1].tap_transport.score # 0-1, higher = stronger TAP binding > exists for any tool (true of the whole TAP field). Treat the score as a useful > pathway signal for prioritization, not a validated oracle. +### ERAP1 trimming + +| Predictor | Kinds produced | Requires | +|---|---|---| +| `ERAMER` | ERAP1 trimming (`erap_trimming`) | [ERAMER](https://github.com/aalokaily/ERAMER) clone with `PWM.xlsx` (set `ERAMER_HOME`) + `openpyxl` | + +ERAP1 trims the N-termini of 9–16mer precursor peptides in the ER down to the +8–10mers MHC-I presents — the step between TAP transport and MHC loading, and +otherwise the last empty stage in the pathway. `ERAMER` scores a precursor by +averaging a per-length position-weight-matrix specificity over each residue +trimmed off as it is cut toward a target epitope length (allele-independent, one +`erap_trimming` prediction per peptide; `score` roughly −1…1, higher = more +likely trimmed). + +ERAMER is **GPLv3** and its PWM ships in a GPL-licensed `PWM.xlsx`, so mhctools +vendors neither: this is a clean-room Python-3 reimplementation of the +(Python-2.7) tool's trimming-cascade average that loads the PWM from a +user-provided ERAMER checkout at runtime. Point at the clone with `ERAMER_HOME`. + +```python +from mhctools import ERAMER + +predictor = ERAMER(epitope_length=8) # resolves ERAMER_HOME / ~/ERAMER +results = predictor.predict(["GGGGGVVVVVVAAAEE"]) # a 9-16mer precursor +results[0].erap_trimming.score +``` + +> ⚠️ ERAMER's evaluation is self-reported and ERAP1 trimming is an intrinsically +> noisy signal; treat the score as a pathway prior, not a validated oracle. + ### Immunogenicity | Predictor | Kinds produced | Requires | diff --git a/mhctools/__init__.py b/mhctools/__init__.py index c2e71a2..1b85a18 100644 --- a/mhctools/__init__.py +++ b/mhctools/__init__.py @@ -28,6 +28,7 @@ from .prime import PRIME from .deeptap import DeepTAP from .calis import Calis +from .eramer import ERAMER from .processing_predictor import ( ProcessingPredictor, SCORING_MODES, @@ -84,7 +85,7 @@ def __getattr__(name): raise AttributeError( "module %r has no attribute %r" % (__name__, name)) -__version__ = "3.26.0" +__version__ = "3.27.0" __all__ = [ "Prediction", @@ -115,6 +116,7 @@ def __getattr__(name): "PRIME", "DeepTAP", "Calis", + "ERAMER", "MHCflurry", "MHCflurry_Affinity", "ProcessingPredictor", diff --git a/mhctools/annotate.py b/mhctools/annotate.py index c550fa8..6b43341 100644 --- a/mhctools/annotate.py +++ b/mhctools/annotate.py @@ -72,6 +72,7 @@ "stability": (Kind.pMHC_stability, "value"), "immunogenicity": (Kind.immunogenicity, "score"), "tap_transport": (Kind.tap_transport, "score"), + "erap_trimming": (Kind.erap_trimming, "score"), } diff --git a/mhctools/cli/args.py b/mhctools/cli/args.py index 5761f54..3528678 100644 --- a/mhctools/cli/args.py +++ b/mhctools/cli/args.py @@ -66,6 +66,7 @@ PRIME, DeepTAP, Calis, + ERAMER, ) @@ -173,6 +174,7 @@ def __hash__(self): "prime": PRIME, "deeptap": DeepTAP, "calis": Calis, + "eramer": ERAMER, } diff --git a/mhctools/eramer.py b/mhctools/eramer.py new file mode 100644 index 0000000..e4b709f --- /dev/null +++ b/mhctools/eramer.py @@ -0,0 +1,281 @@ +# Copyright (c) 2016. Mount Sinai School of Medicine +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ERAMER — ERAP1 trimming prediction, filling mhctools' `erap_trimming` gap. + +ERAP1 (ER aminopeptidase 1) trims the N-termini of 9-16mer precursor peptides in +the ER down to the 8-10mers that MHC-I presents — the processing step between +TAP transport and MHC loading, and previously the one empty ``Kind`` in +mhctools. ERAMER (Al-okaily 2024) models ERAP1 specificity as a per-length +position-weight matrix and scores a precursor by averaging the PWM specificity +over each residue trimmed off as it is cut down to a target epitope length. + +Licensing / why nothing is vendored +----------------------------------- +ERAMER is **GPLv3** and its PWM ships in a GPL-licensed ``PWM.xlsx``; mhctools is +Apache-2.0 and vendors neither. Instead this is a clean-room Python-3 +reimplementation of the (simple, factual) trimming-cascade average — the +upstream tool is Python 2.7 — that loads the PWM from a user-provided ERAMER +checkout at runtime, exactly as the netMHC / MixMHCpred wrappers read +user-provided model files. Point at the checkout with ``ERAMER_HOME`` (or pass +``eramer_home=`` / ``pwm_path=``). + +Upstream: https://github.com/aalokaily/ERAMER +Cite: Al-okaily et al., *Comput. Biol. Med.* 2024 — "ERAMER: A novel in silico +tool for prediction of ERAP1 enzyme trimming". + +Note on interpretation: ERAP1 trimming is a genuine but noisy processing signal +and ERAMER's evaluation is self-reported; treat the score (roughly -1..1, higher += more likely trimmed) as a pathway prior, not a validated oracle. +""" + +import os +from os.path import isdir, isfile, join + +import pandas as pd + +from .pred import COLUMNS, Kind, PeptideResult, Prediction + +# ERAP1 processes precursors in this length range; ERAMER ships one PWM sheet +# per precursor length in [9, 16]. +ERAMER_MIN_PRECURSOR_LENGTH = 9 +ERAMER_MAX_PRECURSOR_LENGTH = 16 +_PWM_LENGTHS = tuple(range( + ERAMER_MIN_PRECURSOR_LENGTH, ERAMER_MAX_PRECURSOR_LENGTH + 1)) +_VALID_AMINO_ACIDS = frozenset("ACDEFGHIKLMNPQRSTVWY") + + +_MISSING_PWM_HELP = ( + "Set ERAMER_HOME to a clone of https://github.com/aalokaily/ERAMER " + "(or pass eramer_home= / pwm_path=). mhctools does not vendor the " + "GPL-licensed PWM.") + + +def _find_pwm_path(eramer_home=None, pwm_path=None): + """Resolve the path to ERAMER's ``PWM.xlsx``. + + An explicit *pwm_path* or *eramer_home* is honored exactly (and must exist — + no silent fallback). Otherwise the resolver tries ``$ERAMER_PWM``, then + ``$ERAMER_HOME/PWM.xlsx``, then ``~/ERAMER/PWM.xlsx``. + """ + if pwm_path: + if isfile(pwm_path): + return pwm_path + raise FileNotFoundError( + "ERAMER PWM not found at pwm_path=%r." % pwm_path) + if eramer_home: + candidate = join(eramer_home, "PWM.xlsx") + if isfile(candidate): + return candidate + raise FileNotFoundError( + "PWM.xlsx not found in eramer_home=%r." % eramer_home) + + candidates = [] + if os.environ.get("ERAMER_PWM"): + candidates.append(os.environ["ERAMER_PWM"]) + if os.environ.get("ERAMER_HOME"): + candidates.append(join(os.environ["ERAMER_HOME"], "PWM.xlsx")) + home = join(os.path.expanduser("~"), "ERAMER") + if isdir(home): + candidates.append(join(home, "PWM.xlsx")) + for candidate in candidates: + if isfile(candidate): + return candidate + raise FileNotFoundError("ERAMER's PWM.xlsx not found. " + _MISSING_PWM_HELP) + + +def load_pwm(pwm_path): + """Load ERAMER's PWM into ``{length: {amino_acid: [weight_per_position]}}``. + + Reads the ``Length 9`` .. ``Length 16`` sheets: each has 20 amino-acid rows + (letter in column B, per-position weights in the following columns). + """ + try: + import openpyxl + except ImportError as e: + raise ImportError( + "Reading ERAMER's PWM.xlsx requires openpyxl (`pip install " + "openpyxl`).") from e + + workbook = openpyxl.load_workbook(pwm_path, data_only=True, read_only=True) + weights_by_length = {} + for length in _PWM_LENGTHS: + sheet_name = "Length %d" % length + if sheet_name not in workbook.sheetnames: + raise ValueError( + "ERAMER PWM.xlsx is missing sheet %r" % sheet_name) + worksheet = workbook[sheet_name] + aa_weights = {} + # 20 amino-acid rows: openpyxl rows 2..21, letter in col 2, the L + # per-position weights in cols 3..(2 + length). + for row in range(2, 22): + aa = worksheet.cell(row=row, column=2).value + if not aa: + continue + aa = str(aa).strip().upper() + aa_weights[aa] = [ + float(worksheet.cell(row=row, column=3 + pos).value) + for pos in range(length)] + missing = _VALID_AMINO_ACIDS - set(aa_weights) + if missing: + raise ValueError( + "ERAMER PWM sheet %r missing amino acid(s): %s" + % (sheet_name, "".join(sorted(missing)))) + weights_by_length[length] = aa_weights + workbook.close() + return weights_by_length + + +def _specificity(peptide, aa_weights): + """Mean PWM specificity of a peptide (one trimming intermediate).""" + length = len(peptide) + total = sum(aa_weights[peptide[i]][i] for i in range(length)) + # Rounded per-residue-average, matching upstream ERAMER's compute_specificty. + return round(total / length, 6) + + +def eramer_score(precursor, epitope_length, weights_by_length): + """ERAMER trimming score: mean specificity over the trimming cascade. + + As the precursor is trimmed one residue at a time from length ``L`` down to + ``epitope_length + 1``, each intermediate C-terminal ``l``-mer is scored by + its PWM specificity; the returned score is their mean (higher = more likely + trimmed by ERAP1). + + Returns ``None`` if the precursor is already at/below ``epitope_length`` + (nothing to trim). + """ + length = len(precursor) + scores = [ + _specificity(precursor[-sub_len:], weights_by_length[sub_len]) + for sub_len in range(length, epitope_length, -1)] + if not scores: + return None + return sum(scores) / len(scores) + + +class ERAMER: + """ERAMER ERAP1-trimming predictor (clean-room reimplementation). + + Allele-independent: ``predict()`` returns one ``Kind.erap_trimming`` + prediction per peptide (the peptide is treated as an ERAP1 *precursor*), + with an empty ``allele``. + + Parameters + ---------- + epitope_length : int + Target epitope length the precursor is trimmed toward; the cascade runs + from the precursor length down to ``epitope_length + 1``. Default 8 (so + every valid 9-16mer precursor yields a score). Must be in 8-15. + eramer_home : str, optional + Path to an ERAMER checkout (containing ``PWM.xlsx``). Resolved from the + argument, then ``$ERAMER_HOME``, then ``~/ERAMER``. + pwm_path : str, optional + Direct path to ``PWM.xlsx`` (overrides *eramer_home*). + """ + + def __init__(self, epitope_length=8, eramer_home=None, pwm_path=None): + if not (8 <= epitope_length <= ERAMER_MAX_PRECURSOR_LENGTH - 1): + raise ValueError( + "epitope_length must be in 8-%d (the shortest trimmed " + "intermediate must still be a >=9mer with a PWM); got %d" + % (ERAMER_MAX_PRECURSOR_LENGTH - 1, epitope_length)) + self.epitope_length = epitope_length + self.pwm_path = _find_pwm_path(eramer_home, pwm_path) + self._weights_by_length = None + + def __str__(self): + return "ERAMER(epitope_length=%d, pwm_path=%r)" % ( + self.epitope_length, self.pwm_path) + + def __repr__(self): + return str(self) + + def _predictor_name(self): + return "eramer" + + def _default_pred_kind(self): + return Kind.erap_trimming + + def kind_support(self): + return { + Kind.erap_trimming: { + "mhc_dependence": "none", + "mhc_class": "I", + }, + } + + @property + def supported_kinds(self): + return tuple(self.kind_support()) + + def _ensure_loaded(self): + if self._weights_by_length is None: + self._weights_by_length = load_pwm(self.pwm_path) + return self._weights_by_length + + def _check_peptides(self, peptides): + for peptide in peptides: + n = len(peptide) + if not (ERAMER_MIN_PRECURSOR_LENGTH <= n + <= ERAMER_MAX_PRECURSOR_LENGTH): + raise ValueError( + "ERAMER precursors must be %d-%d residues; got %r (length " + "%d)" % (ERAMER_MIN_PRECURSOR_LENGTH, + ERAMER_MAX_PRECURSOR_LENGTH, peptide, n)) + if n <= self.epitope_length: + raise ValueError( + "Precursor %r (length %d) is not longer than the target " + "epitope_length %d, so there is nothing to trim" + % (peptide, n, self.epitope_length)) + invalid = set(peptide) - _VALID_AMINO_ACIDS + if invalid: + raise ValueError( + "Peptide %r contains non-standard amino acid(s): %s" + % (peptide, "".join(sorted(invalid)))) + + def predict(self, peptides): + """Predict ERAP1 trimming for a list of precursor peptides. + + Returns + ------- + list of PeptideResult + One entry per input peptide, each holding a single + ``Kind.erap_trimming`` prediction (empty ``allele``; ``score`` is the + ERAMER trimming score, higher = more likely trimmed). + """ + if isinstance(peptides, str): + peptides = [peptides] + peptide_list = [str(p).strip().upper() for p in peptides] + self._check_peptides(peptide_list) + weights_by_length = self._ensure_loaded() + + results = [] + for peptide in peptide_list: + score = eramer_score( + peptide, self.epitope_length, weights_by_length) + preds = () if score is None else (Prediction( + kind=Kind.erap_trimming, + score=float(score), + peptide=peptide, + predictor_name="eramer"),) + results.append(PeptideResult(preds=preds)) + return results + + def predict_dataframe(self, peptides, sample_name=""): + """``predict()`` flattened to a DataFrame.""" + dfs = [pp.to_dataframe(sample_name) for pp in self.predict(peptides)] + if not dfs: + return pd.DataFrame(columns=COLUMNS) + return pd.concat(dfs, ignore_index=True) diff --git a/mhctools/pred.py b/mhctools/pred.py index deed5ef..668b2f0 100644 --- a/mhctools/pred.py +++ b/mhctools/pred.py @@ -293,6 +293,11 @@ def tap_transport(self) -> Optional[Prediction]: """Best TAP transport prediction, or None.""" return self.best_by_score(Kind.tap_transport) + @property + def erap_trimming(self) -> Optional[Prediction]: + """Best ERAP1 trimming prediction, or None.""" + return self.best_by_score(Kind.erap_trimming) + @property def tcr_binding(self) -> Optional[Prediction]: """Best pMHC:TCR binding prediction, or None.""" diff --git a/tests/test_eramer.py b/tests/test_eramer.py new file mode 100644 index 0000000..1884b3f --- /dev/null +++ b/tests/test_eramer.py @@ -0,0 +1,134 @@ +# Copyright (c) 2016. Mount Sinai School of Medicine +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the ERAMER ERAP1-trimming predictor. + +The trimming-cascade math is tested binary-free with a synthetic PWM. The +end-to-end tests run only when an ERAMER checkout with ``PWM.xlsx`` is available +(``ERAMER_HOME``) and openpyxl is installed. +""" + +import os +from os.path import isfile, join + +import pytest + +from mhctools import ERAMER, Kind +from mhctools.eramer import _specificity, eramer_score + + +# --- cascade math (synthetic PWM, no xlsx) ---------------------------------- + +def test_specificity_is_position_mean(): + aa_weights = {"A": [0.2, 0.4, 0.6], "C": [0.0, 0.0, 0.0]} + assert _specificity("AAA", aa_weights) == 0.4 # (0.2+0.4+0.6)/3 + assert _specificity("CCC", aa_weights) == 0.0 + assert _specificity("AAC", aa_weights) == 0.2 # (0.2+0.4+0.0)/3 + + +def test_eramer_score_cascade_average(): + # Two synthetic precursor lengths: score('AAA' via len-3 sheet)=0.3, + # score('AA' via len-2 sheet)=0.5. + wbl = {3: {"A": [0.3, 0.3, 0.3]}, 2: {"A": [0.5, 0.5]}} + # epitope_length 1 -> trim 'AAA'(l=3) then 'AA'(l=2); mean(0.3, 0.5) = 0.4 + assert eramer_score("AAA", 1, wbl) == pytest.approx(0.4) + # epitope_length 2 -> only the full 3-mer (l=3): 0.3 + assert eramer_score("AAA", 2, wbl) == pytest.approx(0.3) + # nothing to trim + assert eramer_score("AAA", 3, wbl) is None + + +# --- construction / validation (needs a PWM path but not a real PWM) -------- + +@pytest.fixture +def stub_predictor(tmp_path): + # ERAMER validates peptides before it lazily loads the PWM, so a stub file + # is enough to exercise construction + validation without a real workbook. + pwm = tmp_path / "PWM.xlsx" + pwm.write_bytes(b"not a real workbook") + return ERAMER(pwm_path=str(pwm)) + + +def test_default_kind_and_support(stub_predictor): + assert stub_predictor._default_pred_kind() == Kind.erap_trimming + support = stub_predictor.kind_support()[Kind.erap_trimming] + assert support["mhc_dependence"] == "none" + assert support["mhc_class"] == "I" + assert stub_predictor.supported_kinds == (Kind.erap_trimming,) + + +def test_rejects_bad_epitope_length(tmp_path): + pwm = tmp_path / "PWM.xlsx" + pwm.write_bytes(b"x") + with pytest.raises(ValueError, match="epitope_length"): + ERAMER(epitope_length=7, pwm_path=str(pwm)) + with pytest.raises(ValueError, match="epitope_length"): + ERAMER(epitope_length=16, pwm_path=str(pwm)) + + +def test_rejects_missing_pwm(tmp_path): + with pytest.raises(FileNotFoundError, match="not found"): + ERAMER(pwm_path=str(tmp_path / "nope.xlsx")) + + +def test_peptide_validation(stub_predictor): + with pytest.raises(ValueError, match="9-16 residues"): + stub_predictor.predict(["AAAAAAAA"]) # 8-mer, too short + with pytest.raises(ValueError, match="9-16 residues"): + stub_predictor.predict(["A" * 17]) # 17-mer, too long + with pytest.raises(ValueError, match="non-standard amino acid"): + stub_predictor.predict(["GGGGGVVVVBX"]) # 11-mer, bad residues + + +# --- end-to-end (requires an ERAMER checkout + openpyxl) -------------------- + +ERAMER_HOME = os.environ.get("ERAMER_HOME") +_has_eramer = bool(ERAMER_HOME) and isfile(join(ERAMER_HOME, "PWM.xlsx")) +try: + import openpyxl # noqa: F401 + _has_openpyxl = True +except ImportError: + _has_openpyxl = False + +requires_eramer = pytest.mark.skipif( + not (_has_eramer and _has_openpyxl), + reason="ERAMER not installed (set ERAMER_HOME to a clone with PWM.xlsx; " + "needs openpyxl)") + + +@requires_eramer +def test_eramer_end_to_end_matches_reference(): + # Reference values are ERAMER v1.0's own sample output (README/test.fasta). + predictor = ERAMER(epitope_length=8, eramer_home=ERAMER_HOME) + results = predictor.predict(["GGGGGVVVVVVAAAEE", "LLLLLLLLLLLAAAAA"]) + + by_peptide = {r.peptide: r.preds[0] for r in results} + assert by_peptide["GGGGGVVVVVVAAAEE"].kind == Kind.erap_trimming + assert by_peptide["GGGGGVVVVVVAAAEE"].allele == "" + assert by_peptide["GGGGGVVVVVVAAAEE"].score == pytest.approx( + 0.085322875, abs=1e-6) + assert by_peptide["LLLLLLLLLLLAAAAA"].score == pytest.approx( + 0.273310375, abs=1e-6) + assert results[0].erap_trimming is by_peptide["GGGGGVVVVVVAAAEE"] + + +@requires_eramer +def test_eramer_epitope_length_changes_cascade(): + # epitope_length 9 drops the shortest (9-mer) trimming step vs default 8. + e8 = ERAMER(epitope_length=8, eramer_home=ERAMER_HOME) + e9 = ERAMER(epitope_length=9, eramer_home=ERAMER_HOME) + (r8,) = e8.predict(["GGGGGVVVVVVAAAEE"]) + (r9,) = e9.predict(["GGGGGVVVVVVAAAEE"]) + assert r8.preds[0].score == pytest.approx(0.085322875, abs=1e-6) + assert r9.preds[0].score == pytest.approx(0.078594142857, abs=1e-6)