Skip to content

Commit 804d8c4

Browse files
authored
feat: annotate indel hits with edit type and query position (Indel Positions column) (#27)
1 parent bf2cade commit 804d8c4

3 files changed

Lines changed: 113 additions & 13 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ df = Matcher(
103103
max_indels=1
104104
).match()
105105
```
106+
Each indel hit is annotated in an **`Indel Positions`** column with the edit type, residue, and 1-based query position — e.g. `d: A[6]` (deletion of `A` at query position 6) or `i: X[6]` (insertion of `X` before query position 6); an exact match reports `[]`. In a repeat the exact position is ambiguous, so the inclusive range of all valid positions is reported, e.g. `d: A[2,4]`.
107+
106108
Currently limited to `max_indels=1`; mutually exclusive with `max_mismatches`.
107109

108110
#### Best Match

pepmatch/matcher.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,44 @@ def output_matches(df: pl.DataFrame, output_format: str, output_name: str) -> No
2222
elif output_format == 'json':
2323
df.write_json(path)
2424

25+
26+
def _indel_edit(query, matched):
27+
"""Return (kind, residues, low, high) for a single-indel match, or None for an
28+
exact match: kind 'd'/'i', the deleted query or inserted protein residue(s), and
29+
[low, high] the inclusive 1-based range of valid positions (a run of equivalent
30+
positions in a repeat)."""
31+
if matched == query:
32+
return None
33+
L = len(query)
34+
if len(matched) < L:
35+
# interior positions only — query-terminal deletions are barred
36+
positions = [i for i in range(2, L) if query[:i - 1] + query[i:] == matched]
37+
if not positions:
38+
return None
39+
return ('d', query[positions[0] - 1], positions[0], positions[-1])
40+
# interior matched residues only — boundary insertions are barred
41+
positions, residue = [], None
42+
for k in range(1, len(matched) - 1):
43+
if matched[:k] + matched[k + 1:] == query:
44+
positions.append(k + 1) # 1-based query position the inserted residue precedes
45+
residue = matched[k]
46+
if not positions:
47+
return None
48+
return ('i', residue, positions[0], positions[-1])
49+
50+
51+
def format_indel_positions(query, matched):
52+
"""Render the Indel Positions column, e.g. 'd: A[6]', 'i: X[2,4]', or '[]' for
53+
an exact match. Positions are 1-based; a range [low,high] collapses to [n] when
54+
the position is unambiguous."""
55+
edit = _indel_edit(query, matched)
56+
if edit is None:
57+
return '[]'
58+
kind, residues, low, high = edit
59+
span = f'[{low}]' if low == high else f'[{low},{high}]'
60+
return f'{kind}: {residues}{span}'
61+
62+
2563
class Matcher:
2664
"""Searches query peptides against a preprocessed proteome index
2765
and returns matches as a Polars DataFrame or output file."""
@@ -360,13 +398,15 @@ def _metadata_table(self) -> pl.DataFrame:
360398
])
361399

362400
def _final_columns(self, is_indels):
363-
# One edit-count column per mode, in a fixed position: Indels for indel search,
364-
# Mismatches for every other mode. Never both — an all-zero twin column would
365-
# imply a search that didn't run.
401+
# One edit-count column and one edit-position column per mode, in fixed
402+
# positions: Indels / Indel Positions for indel search, Mismatches / Mutated
403+
# Positions for every other mode. Never both — an all-zero/empty twin column
404+
# would imply a search that didn't run.
366405
edit_col = 'Indels' if is_indels else 'Mismatches'
406+
pos_col = 'Indel Positions' if is_indels else 'Mutated Positions'
367407
return [
368408
'Query ID','Query Sequence','Matched Sequence','Protein ID','Protein Name','Species',
369-
'Taxon ID','Gene', edit_col, 'Mutated Positions','Index start','Index end',
409+
'Taxon ID','Gene', edit_col, pos_col,'Index start','Index end',
370410
'Protein Existence Level','Gene Priority','SwissProt Reviewed',
371411
]
372412

@@ -379,6 +419,7 @@ def _to_dataframe(self, cols, is_indels=False):
379419
# use for mismatches, so the values are identical in shape — only the column name
380420
# differs by mode (Indels vs Mismatches), and only one is ever emitted.
381421
edit_col = 'Indels' if is_indels else 'Mismatches'
422+
pos_col = 'Indel Positions' if is_indels else 'Mutated Positions'
382423
final_columns = self._final_columns(is_indels)
383424

384425
if not qid:
@@ -388,13 +429,21 @@ def _to_dataframe(self, cols, is_indels=False):
388429
schema['SwissProt Reviewed'] = pl.Boolean
389430
return pl.DataFrame(schema=schema)
390431

432+
# In indel mode the edit position is derivable from (query, matched), so we
433+
# compute Indel Positions here rather than in Rust; miss rows (no match) stay null.
434+
if is_indels:
435+
positions = [format_indel_positions(q, m) if m is not None else None
436+
for q, m in zip(qseq, matched)]
437+
else:
438+
positions = mutated
439+
391440
base = pl.DataFrame({
392441
'Query ID': qid,
393442
'Query Sequence': qseq,
394443
'Matched Sequence': matched,
395444
'protein_num': pl.Series(pnum, dtype=pl.UInt32),
396445
edit_col: pl.Series(mm, dtype=pl.Int64),
397-
'Mutated Positions': mutated,
446+
pos_col: positions,
398447
'Index start': pl.Series(istart, dtype=pl.Int64),
399448
'Index end': pl.Series(iend, dtype=pl.Int64),
400449
})

pepmatch/tests/test_indel_search.py

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import polars.testing as plt
44
from pathlib import Path
55
from pepmatch import Matcher
6+
from pepmatch.matcher import format_indel_positions
67

78
@pytest.fixture
89
def proteome_path() -> Path:
@@ -182,26 +183,74 @@ def test_indel_multi_hit_different_proteins(tmp_path):
182183

183184

184185
def test_indel_mode_emits_indels_column_only(proteome_path):
185-
# One edit-count column per mode: an indel search reports counts in an Indels
186-
# column and must NOT carry a separate always-zero Mismatches column.
186+
# One edit-count and one edit-position column per mode: an indel search reports
187+
# Indels + Indel Positions and must NOT carry the mismatch-mode twins.
187188
df = Matcher(
188189
query=['NALVEATRFC'],
189190
proteome_file=proteome_path,
190191
max_indels=1,
191192
output_format='dataframe'
192193
).match()
193-
assert 'Indels' in df.columns
194-
assert 'Mismatches' not in df.columns
194+
assert 'Indels' in df.columns and 'Indel Positions' in df.columns
195+
assert 'Mismatches' not in df.columns and 'Mutated Positions' not in df.columns
195196

196197

197198
def test_mismatch_mode_emits_mismatches_column_only(proteome_path):
198-
# The mirror: a non-indel search keeps its Mismatches column and must NOT gain
199-
# an always-zero Indels column suggesting an indel search that never ran.
199+
# The mirror: a non-indel search keeps Mismatches + Mutated Positions and must
200+
# NOT gain the indel-mode twins suggesting an indel search that never ran.
200201
df = Matcher(
201202
query=['NALVEATRFC'],
202203
proteome_file=proteome_path,
203204
max_mismatches=0,
204205
output_format='dataframe'
205206
).match()
206-
assert 'Mismatches' in df.columns
207-
assert 'Indels' not in df.columns
207+
assert 'Mismatches' in df.columns and 'Mutated Positions' in df.columns
208+
assert 'Indels' not in df.columns and 'Indel Positions' not in df.columns
209+
210+
211+
def test_indel_positions_annotation_unit():
212+
# Hand-verified annotations, format `<d|i>: <residues>[<pos or range>]`, 1-based.
213+
# Deletion residue comes from the query, insertion residue from the protein. In a
214+
# repeat the exact position is ambiguous, so a range of all valid positions is
215+
# reported (collapsing to a single number when unambiguous).
216+
assert format_indel_positions('ABCDEF', 'ABCDEF') == '[]' # exact
217+
assert format_indel_positions('YYADGY', 'YADGY') == 'd: Y[2]' # only pos 2 (1 is terminal)
218+
assert format_indel_positions('NALVEATRFC', 'NALVETRFC') == 'd: A[6]' # the 2nd A, unambiguous
219+
assert format_indel_positions('ABCDEF', 'ABXCDEF') == 'i: X[3]' # X inserted before C
220+
assert format_indel_positions('AAAAA', 'AAAA') == 'd: A[2,4]' # deletable at 2, 3 or 4
221+
assert format_indel_positions('AAAAAA', 'AAAAA') == 'd: A[2,5]'
222+
assert format_indel_positions('AAAAAA', 'AAAAAAA') == 'i: A[2,6]' # insertable across the run
223+
224+
225+
def test_indel_positions_deletion_end_to_end(tmp_path):
226+
# Query NALVEATRFC vs a protein missing the 2nd A -> matched NALVETRFC,
227+
# annotated as a deletion of A at query position 6.
228+
proteome_path = tmp_path / 'proteome.fasta'
229+
proteome_path.write_text('>P\nMKVNALVETRFCGHI\n')
230+
df = Matcher(
231+
query=['NALVEATRFC'],
232+
proteome_file=str(proteome_path),
233+
max_indels=1,
234+
preprocessed_files_path=str(tmp_path),
235+
output_format='dataframe'
236+
).match()
237+
row = df.filter(pl.col('Matched Sequence') == 'NALVETRFC')
238+
assert row.height == 1
239+
assert row['Indel Positions'].item() == 'd: A[6]'
240+
241+
242+
def test_indel_positions_insertion_end_to_end(tmp_path):
243+
# Query NALVEATRFC vs a protein with an extra X after E -> matched NALVEXATRFC,
244+
# annotated as an insertion of X before query position 6.
245+
proteome_path = tmp_path / 'proteome.fasta'
246+
proteome_path.write_text('>P\nMKVNALVEXATRFCGHI\n')
247+
df = Matcher(
248+
query=['NALVEATRFC'],
249+
proteome_file=str(proteome_path),
250+
max_indels=1,
251+
preprocessed_files_path=str(tmp_path),
252+
output_format='dataframe'
253+
).match()
254+
row = df.filter(pl.col('Matched Sequence') == 'NALVEXATRFC')
255+
assert row.height == 1
256+
assert row['Indel Positions'].item() == 'i: X[6]'

0 commit comments

Comments
 (0)