peplocus 0.5.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
peplocus/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """PepLocus: peptide coordinates directly from genome assemblies."""
2
+ __version__ = "0.5.6"
3
+
4
+
5
+ def map_peptides(assembly=None, peptides=None, out=None, **options):
6
+ """Map peptide-file paths with CLI-equivalent options; return summary dict."""
7
+ from .api import map_peptides as run
8
+ return run(assembly, peptides, out, **options)
9
+
10
+
11
+ __all__ = ["__version__", "map_peptides"]
peplocus/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
peplocus/annotation.py ADDED
@@ -0,0 +1,103 @@
1
+ """Read transcript exon structures from GTF and GFF3-style Helixer annotations."""
2
+ from collections import Counter, defaultdict
3
+ import gzip
4
+ import re
5
+ from urllib.parse import unquote
6
+
7
+ from .junctions import Junction, motif
8
+
9
+
10
+ _ATTRIBUTE = re.compile(
11
+ r'(?:^|;)\s*([^\s=;]+)\s*(?:=\s*|\s+)'
12
+ r'("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|[^;]*)'
13
+ )
14
+
15
+
16
+ def annotation_parents(attributes):
17
+ """Read GTF/GFF2/GFF3 relationships without grouping separate isoforms by gene.
18
+
19
+ Split GFF3 Parent lists before URL decoding: an encoded comma is part of an ID.
20
+ Protein-only and bare transcript labels support older predictor output.
21
+ """
22
+ attrs = {}
23
+ for match in _ATTRIBUTE.finditer(attributes):
24
+ key, value = match.groups()
25
+ value = value.strip()
26
+ quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}
27
+ if quoted:
28
+ value = re.sub(r'\\([\\"\'])', r'\1', value[1:-1])
29
+ attrs[key.lower()] = value
30
+ for key in ('transcript_id', 'parent', 'protein_id'):
31
+ value = attrs.get(key)
32
+ if value and value != '.':
33
+ parts = value.split(',') if key == 'parent' else [value]
34
+ return [unquote(part.strip()) for part in parts if part.strip()]
35
+ bare = attributes.strip().rstrip(';').strip()
36
+ if re.fullmatch(r'[\w.:%+|/-]+', bare) and bare != '.':
37
+ return [unquote(bare)]
38
+ return []
39
+
40
+
41
+ def read_annotation(path, fasta, lengths):
42
+ """Return exon-adjacency junctions, without pretending annotation is read support.
43
+
44
+ Exons are preferred per transcript; CDS intervals are a fallback when exons
45
+ are absent. CDS phase is not trimmed: every junction is searched in all frames,
46
+ preserving codons split across introns. Annotated introns are not BAM-filtered.
47
+ """
48
+ transcripts = defaultdict(lambda: {"exon": set(), "CDS": set()})
49
+ identities = {}
50
+ stats = Counter()
51
+ opener = gzip.open if str(path).endswith('.gz') else open
52
+ with opener(path, 'rt') as handle:
53
+ for number, line in enumerate(handle, 1):
54
+ if line.startswith('##FASTA'):
55
+ break
56
+ if not line.strip() or line.startswith('#'):
57
+ continue
58
+ fields = line.rstrip('\n').split('\t')
59
+ if len(fields) != 9:
60
+ raise ValueError(f"annotation line {number}: expected 9 GTF/GFF columns")
61
+ chrom, _, feature, start, end, _, strand, _, attributes = fields
62
+ feature = feature.lower()
63
+ if feature not in {'exon', 'cds'}:
64
+ continue
65
+ feature = 'CDS' if feature == 'cds' else 'exon'
66
+ stats['feature_rows'] += 1
67
+ if chrom not in lengths:
68
+ stats['feature_rows_unknown_contig'] += 1
69
+ continue
70
+ start, end = int(start)-1, int(end)
71
+ if not 0 <= start < end <= lengths[chrom]:
72
+ raise ValueError(f"annotation line {number}: interval outside assembly")
73
+ if strand not in {'+', '-'}:
74
+ raise ValueError(f"annotation line {number}: exon/CDS requires + or - strand")
75
+ parents = annotation_parents(attributes)
76
+ if not parents:
77
+ raise ValueError(f"annotation line {number}: exon/CDS lacks transcript_id or Parent (or protein_id/bare transcript label); gene-only features cannot resolve isoforms")
78
+ for parent in parents:
79
+ identity = (chrom, strand)
80
+ if parent in identities and identities[parent] != identity:
81
+ raise ValueError(f"annotation transcript {parent!r} appears on conflicting contigs/strands")
82
+ identities[parent] = identity
83
+ transcripts[(chrom, strand, parent)][feature].add((start, end))
84
+ if not transcripts:
85
+ raise ValueError('annotation contains no usable exon/CDS transcripts on assembly contigs')
86
+ junctions = {}
87
+ for (chrom, strand, parent), features in transcripts.items():
88
+ exons = sorted(features['exon'] or features['CDS'])
89
+ stats['transcripts'] += 1
90
+ stats['transcripts_exon' if features['exon'] else 'transcripts_cds_fallback'] += 1
91
+ # Merge touching/overlapping segments; they do not define an intron.
92
+ merged = []
93
+ for a,b in exons:
94
+ if merged and a <= merged[-1][1]:
95
+ merged[-1] = (merged[-1][0], max(b, merged[-1][1]))
96
+ else:
97
+ merged.append((a,b))
98
+ for left, right in zip(merged, merged[1:]):
99
+ start, end = left[1], right[0]
100
+ key = (chrom, start, end, strand)
101
+ junctions[key] = Junction(chrom, start, end, strand, 0, 'annotation', motif(fasta, chrom, start, end, strand))
102
+ stats['junctions_retained'] = len(junctions)
103
+ return sorted(junctions.values()), dict(stats)
peplocus/api.py ADDED
@@ -0,0 +1,43 @@
1
+ """Python entry point using exactly the same defaults as the command line."""
2
+ import argparse
3
+
4
+
5
+ def map_peptides(assembly=None, peptides=None, out=None, *, threads=8, **options):
6
+ """Map a peptide file; return the run summary dictionary.
7
+
8
+ Keyword options use CLI names with underscores, e.g. gtf=..., bam=...,
9
+ rescue_one_edit=True, min_rescue_length=9, chunk_size=2000000.
10
+ Invalid options raise ValueError. Inputs are paths, never modified in place.
11
+ """
12
+ if "genome" in options:
13
+ if assembly is not None:
14
+ raise ValueError("use assembly only; genome is a legacy alias")
15
+ assembly=options.pop("genome")
16
+ if assembly is None or peptides is None or out is None:
17
+ raise ValueError("assembly, peptides and out are required")
18
+ from .cli import parser, validate_arguments
19
+ root=parser()
20
+ def error(message):raise ValueError(message)
21
+ root.error=error
22
+ actions={action.dest:action for action in root._actions if action.option_strings}
23
+ if "min_rescue_length" in options:
24
+ if "rescue_min_length" in options:
25
+ raise ValueError("use only min_rescue_length (rescue_min_length is a legacy alias)")
26
+ options["rescue_min_length"]=options.pop("min_rescue_length")
27
+ if "il_equivalent" in options and options["il_equivalent"] is not True:
28
+ raise ValueError("I/L equivalence is always enabled; il_equivalent must be True")
29
+ values=dict(options,threads=threads)
30
+ argv=["--assembly",str(assembly),"--peptides",str(peptides),"--out",str(out)]
31
+ for key,value in values.items():
32
+ if key not in actions or key in {"help","help_advanced","version","genome","peptides","out"}:
33
+ raise ValueError("unknown or reserved mapping option: "+key)
34
+ action=actions[key]
35
+ flag=action.option_strings[0]
36
+ if isinstance(action,argparse._StoreTrueAction):
37
+ if not isinstance(value,bool):raise ValueError(key+" must be boolean")
38
+ if value:argv.append(flag)
39
+ elif value is not None:
40
+ argv.extend([flag,str(value)])
41
+ args=root.parse_args(argv)
42
+ validate_arguments(args)
43
+ return args.func(args)
peplocus/cli.py ADDED
@@ -0,0 +1,232 @@
1
+ """PepLocus command-line interface."""
2
+ import argparse
3
+ import csv
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+
10
+ import pysam
11
+
12
+ from . import __version__
13
+ from .io import prepare_reference
14
+ from .junctions import extract_bam, write_junctions
15
+ from .annotation import read_annotation
16
+ from .output import EDIT_FIELDS
17
+ from .pipeline import map_peptides
18
+ from .rescue import one_edit
19
+ from .sequence import canonical, join_blocks, translate
20
+
21
+
22
+ class _HelpParser(argparse.ArgumentParser):
23
+ """Keep tuning flags parseable while presenting a small everyday interface."""
24
+ def _view(self, render):
25
+ hidden=[] if getattr(self,"_show_advanced",False) else getattr(self,"_advanced_actions",[])
26
+ saved=[(action,action.help) for action in hidden]
27
+ try:
28
+ for action,_ in saved:action.help=argparse.SUPPRESS
29
+ return render()
30
+ finally:
31
+ for action,help_text in saved:action.help=help_text
32
+
33
+ def parse_args(self, args=None, namespace=None):
34
+ args=list(sys.argv[1:] if args is None else args)
35
+ if args[:1]==["map"]:args=args[1:] # compatibility with older scripts
36
+ return super().parse_args(args, namespace)
37
+
38
+ def format_help(self):return self._view(super().format_help)
39
+ def format_usage(self):return self._view(super().format_usage)
40
+
41
+
42
+ class _AdvancedHelp(argparse.Action):
43
+ def __call__(self, parser, namespace, values, option_string=None):
44
+ parser._show_advanced=True
45
+ parser.print_help()
46
+ parser.exit()
47
+
48
+
49
+ def positive(text):
50
+ value = int(text)
51
+ if value < 1:
52
+ raise argparse.ArgumentTypeError("must be a positive integer")
53
+ return value
54
+
55
+
56
+ def nonnegative(text):
57
+ value = int(text)
58
+ if value < 0:
59
+ raise argparse.ArgumentTypeError("must be zero or a positive integer")
60
+ return value
61
+
62
+
63
+ def junction_options(parser):
64
+ parser.add_argument("--min-mapq", type=nonnegative, default=10, help="minimum BAM mapping quality (default: 10)")
65
+ parser.add_argument("--min-read-anchor", type=positive, default=8, help="aligned bases on each side of a BAM intron (default: 8)")
66
+ parser.add_argument("--min-junction-support", type=positive, default=2, help="minimum primary, nonduplicate alignment observations per junction (default: 2)")
67
+ parser.add_argument("--min-intron", type=positive, default=20, help="minimum BAM/discovery intron length in bp (default: 20)")
68
+ parser.add_argument("--max-intron", type=positive, default=500000, help="maximum BAM/discovery intron length in bp (default: 500000)")
69
+
70
+
71
+ def run_junctions(args):
72
+ out = Path(args.out)
73
+ if out.exists():
74
+ raise ValueError(f"output already exists: {out}")
75
+ out.parent.mkdir(parents=True, exist_ok=True)
76
+ with tempfile.TemporaryDirectory(prefix="peplocus-junctions-") as cache:
77
+ genome, index, lengths = prepare_reference(args.genome, cache)
78
+ with pysam.FastaFile(genome, filepath_index=index) as fasta:
79
+ junctions, stats = [], {}
80
+ if args.bam:
81
+ junctions, bam_stats = extract_bam(args.bam, fasta, lengths, args.min_mapq, args.min_read_anchor, args.min_junction_support, args.min_intron, args.max_intron)
82
+ stats["bam"] = bam_stats
83
+ if args.gtf:
84
+ annotation_junctions, annotation_stats = read_annotation(args.gtf, fasta, lengths)
85
+ junctions.extend(annotation_junctions)
86
+ stats["annotation"] = annotation_stats
87
+ write_junctions(out, junctions)
88
+ print(json.dumps(stats, indent=2))
89
+
90
+
91
+ def run_verify(args):
92
+ count = 0
93
+ with tempfile.TemporaryDirectory(prefix="peplocus-verify-") as cache:
94
+ genome, index, lengths = prepare_reference(args.genome, cache)
95
+ with pysam.FastaFile(genome, filepath_index=index) as fasta, open(args.mappings) as handle:
96
+ reader = csv.DictReader(handle, delimiter="\t")
97
+ if not {"peptide", "chrom", "strand", "blocks", "matched_sequence"} <= set(reader.fieldnames or []):
98
+ raise ValueError("expected a PepLocus mappings.tsv file")
99
+ for row in reader:
100
+ if not all(isinstance(row.get(name),str) and row[name] for name in ("peptide","chrom","strand","blocks","matched_sequence")):
101
+ raise ValueError("mapping row has empty required fields")
102
+ blocks = json.loads(row["blocks"])
103
+ if not isinstance(blocks,list) or not all(isinstance(block,list) and len(block)==2 and
104
+ all(type(value) is int for value in block) for block in blocks):
105
+ raise ValueError("mapping blocks must be a list of integer coordinate pairs")
106
+ if row["strand"] not in {"+", "-"} or row["chrom"] not in lengths or not blocks:
107
+ raise ValueError(f"invalid mapping: {row}")
108
+ previous = -1
109
+ for a,b in blocks:
110
+ if not 0 <= a < b <= lengths[row["chrom"]] or a < previous:
111
+ raise ValueError(f"invalid genomic blocks: {blocks}")
112
+ previous = b
113
+ if sum(b-a for a,b in blocks) != 3*len(row["matched_sequence"]):
114
+ raise ValueError("mapping blocks do not span complete matched codons")
115
+ if "start" in row and (int(row["start"])!=blocks[0][0] or int(row["end"])!=blocks[-1][1]):
116
+ raise ValueError("mapping start/end disagree with blocks")
117
+ observed = translate(join_blocks(fasta, row["chrom"], row["strand"], blocks), args.genetic_code)
118
+ if row.get("match_type") == "one_edit":
119
+ edit = one_edit(row["peptide"], observed, args.il_equivalent)
120
+ valid = (observed == row["matched_sequence"] and edit is not None
121
+ and row.get("edit_distance") == "1"
122
+ and all(str(edit[key]) == row.get(key) for key in
123
+ EDIT_FIELDS))
124
+ else:
125
+ valid = observed == row["matched_sequence"] and canonical(observed,args.il_equivalent) == canonical(row["peptide"],args.il_equivalent)
126
+ if not valid:
127
+ raise ValueError(f"mapping does not reproduce peptide/alignment: {row['peptide']} at {row['chrom']}:{blocks}")
128
+ count += 1
129
+ print(json.dumps({"verified_loci": count, "valid": True}))
130
+
131
+
132
+ def parser(command=None):
133
+ if command in {"junctions", "verify"}:
134
+ return utility_parser(command)
135
+ mapping = _HelpParser(prog="peplocus",
136
+ description=None,
137
+ epilog="For tuning controls: peplocus --help-advanced",
138
+ formatter_class=argparse.RawDescriptionHelpFormatter)
139
+ mapping.add_argument("--version", action="version", version=f"PepLocus {__version__}")
140
+ mapping.add_argument("--help-advanced", action=_AdvancedHelp, nargs=0,
141
+ help="show advanced controls and exit")
142
+ mapping.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True, help="assembly FASTA (plain or gzip)")
143
+ mapping.add_argument("-p", "--peptides", required=True, help="peptide list, FASTA, CSV or TSV")
144
+ mapping.add_argument("--peptide-column", help="column name in CSV/TSV input")
145
+ mapping.add_argument("--out", required=True, help="output directory without existing PepLocus results")
146
+ mapping.add_argument("-g", "--gtf", "--gff", dest="gtf", help="annotation supplying splice junctions (GTF/GFF)")
147
+ mapping.add_argument("-b", "--bam", help="optional RNA-seq BAM; no index required")
148
+ mapping.add_argument("--junctions", help="optional reusable PepLocus junction TSV")
149
+ mapping.add_argument("--threads", type=positive, default=8, help="worker processes (default: 8)")
150
+ mapping.add_argument("--chunk-size", type=positive, default=2000000, help="genomic bases per worker task (default: 2000000)")
151
+ mapping.add_argument("--min-length", type=positive, default=1, help="minimum peptide length; short matches can be highly ambiguous")
152
+ mapping.add_argument("--il-equivalent", action="store_true", default=True, help=argparse.SUPPRESS)
153
+ mapping.add_argument("--genetic-code", type=positive, default=1, help="NCBI translation table; applies to every contig (default: 1)")
154
+ mapping.add_argument("--rescue-one-edit", action="store_true", help="rescue exact-unmapped peptides with one amino-acid edit")
155
+ mapping.add_argument("--min-rescue-length", dest="rescue_min_length", metavar="MIN_LENGTH", type=positive, default=9, help="minimum query length for one-edit rescue (default: 9)")
156
+ mapping.add_argument("--igv-display-bed", action="store_true", help="also write a display-only BED with reversed negative-strand labels and GAPMS-style colors")
157
+ mapping.add_argument("--no-verify", action="store_true", help="skip final extraction/translation verification (enabled by default)")
158
+ junction_options(mapping)
159
+ mapping.add_argument("--max-junctions", type=positive, default=3, help="maximum introns crossed by a supported peptide path (default: 3)")
160
+ mapping.add_argument("--max-paths-per-junction", type=nonnegative, default=1000, help="splice graph path cap per first intron; 0 means unlimited")
161
+ mapping.add_argument("--discover-junctions", action="store_true", help="discover bounded single-intron alignments without annotation")
162
+ mapping.add_argument("--discover-all", action="store_true", help="run discovery for mapped peptides too (requires --discover-junctions)")
163
+ mapping.add_argument("--novel-anchor", type=positive, default=4, help="amino acids required at each terminal discovery anchor (default: 4)")
164
+ mapping.add_argument("--novel-cache-mb", type=nonnegative, default=256, help="memory budget for caching small contigs during discovery; 0 disables it (default: 256 MiB)")
165
+ mapping.add_argument("--max-seed-hits", type=nonnegative, default=2000, help="discovery seed-hit cap per contig/strand; 0 means unlimited")
166
+ mapping.add_argument("--max-novel-pairs", type=nonnegative, default=10000, help="discovery candidate-junction cap per peptide/contig/strand; 0 means unlimited")
167
+ common={"help","version","help_advanced","genome","peptides","out","gtf","bam","threads",
168
+ "rescue_min_length","rescue_one_edit","discover_junctions"}
169
+ mapping._advanced_actions=[action for action in mapping._actions if action.dest not in common]
170
+ advanced=mapping.add_argument_group("Advanced controls")
171
+ for action in mapping._advanced_actions:
172
+ for group in mapping._action_groups:
173
+ if group is not advanced and action in group._group_actions:group._group_actions.remove(action)
174
+ advanced._group_actions.append(action)
175
+ mapping.set_defaults(func=map_peptides, command="map")
176
+ return mapping
177
+
178
+
179
+ def utility_parser(command):
180
+ """Legacy utilities, kept for existing scripts; mapping needs no subcommand."""
181
+ junction = argparse.ArgumentParser(prog="peplocus junctions")
182
+ junction.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True)
183
+ junction.add_argument("-b", "--bam", help="RNA-seq BAM")
184
+ junction.add_argument("-g", "--gtf", "--gff", dest="gtf", help="GTF or Helixer GFF3-style annotation")
185
+ junction.add_argument("--out", required=True, help="new junction TSV path")
186
+ junction_options(junction)
187
+ junction.set_defaults(func=run_junctions, command="junctions")
188
+ verify = argparse.ArgumentParser(prog="peplocus verify")
189
+ verify.add_argument("-a", "--assembly", "--genome", dest="genome", metavar="ASSEMBLY", required=True)
190
+ verify.add_argument("--mappings", required=True)
191
+ verify.add_argument("--il-equivalent", action="store_true", default=True, help=argparse.SUPPRESS)
192
+ verify.add_argument("--genetic-code", type=positive, default=1)
193
+ verify.set_defaults(func=run_verify, command="verify")
194
+ return junction if command == "junctions" else verify
195
+
196
+
197
+ def validate_arguments(args):
198
+ if args.command == "junctions" and not (args.bam or args.gtf):
199
+ raise ValueError("junctions requires --bam and/or --gtf")
200
+ if hasattr(args,"min_intron") and args.min_intron > args.max_intron:
201
+ raise ValueError("--min-intron cannot exceed --max-intron")
202
+ if getattr(args,"discover_all",False) and not args.discover_junctions:
203
+ raise ValueError("--discover-all requires --discover-junctions")
204
+ from Bio.Data import CodonTable
205
+ if getattr(args,"genetic_code",1) not in CodonTable.unambiguous_dna_by_id:
206
+ raise ValueError("--genetic-code must be a valid NCBI DNA translation-table number")
207
+ code=CodonTable.unambiguous_dna_by_id[getattr(args,"genetic_code",1)]
208
+ if set(code.stop_codons) & set(code.forward_table):
209
+ raise ValueError("context-dependent stop codons are unsupported for --genetic-code; choose a table with unambiguous stop semantics")
210
+ if getattr(args,"rescue_min_length",2) < 2:
211
+ raise ValueError("--min-rescue-length must be at least 2")
212
+
213
+
214
+ def main(argv=None):
215
+ argv=list(sys.argv[1:] if argv is None else argv)
216
+ command=argv.pop(0) if argv[:1] and argv[0] in {"junctions", "verify"} else None
217
+ root = parser(command)
218
+ args = root.parse_args(argv)
219
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
220
+ try:
221
+ validate_arguments(args)
222
+ except ValueError as error:
223
+ root.error(str(error))
224
+ try:
225
+ args.func(args)
226
+ except (ValueError, OSError, KeyError) as error:
227
+ logging.getLogger("peplocus").error("%s", error)
228
+ return 2
229
+ except KeyboardInterrupt:
230
+ logging.getLogger("peplocus").error("Interrupted; no completed result summary was published")
231
+ return 130
232
+ return 0
peplocus/digestion.py ADDED
@@ -0,0 +1,83 @@
1
+ """Fast cleavage-context checks shared by exact, splice and one-edit searches."""
2
+ from .sequence import reverse_complement, translate
3
+
4
+ AMINO_ACIDS = frozenset("ACDEFGHIKLMNPQRSTVWY")
5
+ METHIONINE_REMOVAL = frozenset("ACGPSTV")
6
+ NON_PROLINE = AMINO_ACIDS - {"P"}
7
+ C_FOLLOWING = NON_PROLINE | {"*"}
8
+ CONTEXT_FIELDS = ["upstream_aa", "downstream_aa", "query_n_terminal_context", "query_c_terminal_context",
9
+ "reference_n_terminal_context", "reference_c_terminal_context", "query_tryptic", "reference_tryptic",
10
+ "n_cleavage_change", "c_cleavage_change", "context_evidence"]
11
+
12
+
13
+ def cleavage_context(peptide, upstream="", downstream=""):
14
+ """Check both ends; M-based exceptions establish plausibility, not initiation."""
15
+ previous = upstream[-1:] # upstream holds up to two residues, in protein order
16
+ following = downstream[:1]
17
+ n = "nontryptic" if previous in AMINO_ACIDS | {"*"} else "unknown"
18
+ if previous in {"K", "R"} and peptide[:1] in NON_PROLINE:
19
+ n = "tryptic"
20
+ elif peptide.startswith("M"):
21
+ n = "possible_protein_start"
22
+ elif previous == "M" and peptide[:1] in METHIONINE_REMOVAL:
23
+ n = "possible_methionine_removal"
24
+ c = "nontryptic" if following in AMINO_ACIDS | {"*"} else "unknown"
25
+ if following == "*":
26
+ c = "translated_stop"
27
+ elif peptide[-1:] in {"K", "R"} and following in NON_PROLINE:
28
+ c = "tryptic"
29
+ return dict(context_evidence="genome", upstream_aa=upstream[-2:], downstream_aa=following,
30
+ n_terminal_context=n, c_terminal_context=c,
31
+ enzyme_compatible=n not in {"nontryptic","unknown"} and c not in {"nontryptic","unknown"})
32
+
33
+
34
+ def compare_contexts(query, reference, upstream="", downstream=""):
35
+ q=cleavage_context(query,upstream,downstream)
36
+ ref=cleavage_context(reference,upstream,downstream)
37
+ def change(which):
38
+ flank=upstream[-1:] if which=="n" else downstream[:1]
39
+ if flank not in AMINO_ACIDS | {"*"}:return "unresolved"
40
+ if flank=="*":return "no_peptide_bond"
41
+ before=ref[which+"_terminal_context"]=="tryptic"
42
+ after=q[which+"_terminal_context"]=="tryptic"
43
+ return "unchanged" if before==after else "gained" if after else "lost"
44
+ return dict(upstream_aa=upstream[-2:],downstream_aa=downstream[:1],
45
+ query_n_terminal_context=q["n_terminal_context"],query_c_terminal_context=q["c_terminal_context"],
46
+ reference_n_terminal_context=ref["n_terminal_context"],reference_c_terminal_context=ref["c_terminal_context"],
47
+ query_tryptic=q["enzyme_compatible"],reference_tryptic=ref["enzyme_compatible"],
48
+ n_cleavage_change=change("n"),c_cleavage_change=change("c"),context_evidence="genome")
49
+
50
+
51
+ def protein_context(protein, start, end, query=None):
52
+ reference=protein[start:end]
53
+ return compare_contexts(reference if query is None else query,reference,
54
+ protein[max(0,start-2):start],protein[end:end+1])
55
+
56
+
57
+ def genomic_context(reference, chrom, strand, blocks, peptide, table=1):
58
+ """Only outer flanks are needed; internal split codons remain in the peptide."""
59
+ left, right = blocks[0][0], blocks[-1][1]
60
+ size = reference.get_reference_length(chrom)
61
+ if strand == "+":
62
+ upstream = reference.fetch(chrom,max(0,left-6),left)
63
+ downstream = reference.fetch(chrom,right,min(size,right+3))
64
+ # At a contig edge use complete codons nearest the peptide.
65
+ upstream = upstream[len(upstream)%3:]
66
+ else:
67
+ upstream = reverse_complement(reference.fetch(chrom,right,min(size,right+6)))
68
+ downstream = reverse_complement(reference.fetch(chrom,max(0,left-3),left))
69
+ upstream = upstream[len(upstream)%3:]
70
+ return compare_contexts(peptide,peptide,translate(upstream,table),translate(downstream,table))
71
+
72
+
73
+ def extend_path(path, lengths, upstream_nt=6, downstream_nt=3):
74
+ """Add flanking codons only to outer path ends, preserving all introns."""
75
+ chrom,strand,segments,support,motifs,source=path
76
+ segments=list(segments)
77
+ if strand=="+":
78
+ a,b=segments[0];segments[0]=(max(0,a-upstream_nt),b)
79
+ a,b=segments[-1];segments[-1]=(a,min(lengths[chrom],b+downstream_nt))
80
+ else:
81
+ a,b=segments[0];segments[0]=(a,min(lengths[chrom],b+upstream_nt))
82
+ a,b=segments[-1];segments[-1]=(max(0,a-downstream_nt),b)
83
+ return chrom,strand,tuple(segments),support,motifs,source
peplocus/discovery.py ADDED
@@ -0,0 +1,169 @@
1
+ """Optional bounded, annotation-free discovery of single-intron peptide mappings."""
2
+ from array import array
3
+ from bisect import bisect_left, bisect_right
4
+ from collections import Counter, defaultdict
5
+ import logging
6
+
7
+ import ahocorasick
8
+
9
+ from .sequence import canonical, reverse_complement, translate
10
+ from .digestion import genomic_context
11
+
12
+
13
+ LOG = logging.getLogger("peplocus")
14
+
15
+
16
+ def discover(fasta, lengths, peptides, peptide_ids, table=1, il_equivalent=True, anchor=4, min_intron=20, max_intron=500000, max_seed_hits=2000, max_pairs=10000, chunk_size=2000000, stats=None, limited=None, cache_mb=256):
17
+ """Join exact terminal amino-acid seeds and test all nucleotide splice phases.
18
+
19
+ Exhaustive only within the configured single-intron, canonical-motif, anchor,
20
+ intron-length, and repeat/candidate limits. No protein prediction is involved.
21
+ """
22
+ stats = stats if stats is not None else Counter()
23
+ limited = limited if limited is not None else set()
24
+ targets = [pid for pid in peptide_ids if len(peptides[pid]) >= 2 * anchor]
25
+ stats["novel_eligible_peptides"] = len(targets)
26
+ if not targets:
27
+ return
28
+ seeds = {}
29
+ terminal = {}
30
+ for pid in targets:
31
+ seq = canonical(peptides[pid], il_equivalent)
32
+ left, right = seq[:anchor], seq[-anchor:]
33
+ seeds.setdefault(left, len(seeds))
34
+ seeds.setdefault(right, len(seeds))
35
+ terminal[pid] = (seeds[left], seeds[right])
36
+ automaton = ahocorasick.Automaton()
37
+ for word, sid in seeds.items():
38
+ automaton.add_word(word, sid)
39
+ automaton.make_automaton()
40
+ for chrom, length in lengths.items():
41
+ LOG.info("Novel discovery: collecting seed positions on %s (%s bp)", chrom, length)
42
+ # Small random faidx reads are expensive on shared filesystems. Cache
43
+ # a contig only when the forward/reverse/transient copies fit the budget;
44
+ # larger chromosomes retain indexed on-demand access.
45
+ cached_forward = fasta.fetch(chrom).upper() if length * 3 <= cache_mb * 1024 * 1024 else None
46
+ if cached_forward is not None:
47
+ stats["novel_contigs_cached"] += 1
48
+ positions = defaultdict(lambda: array("Q"))
49
+ capped = set()
50
+ overlap = 3 * anchor - 1
51
+ for start in range(0, length, chunk_size):
52
+ core_end = min(length, start + chunk_size)
53
+ end = min(length, core_end + overlap)
54
+ forward = cached_forward[start:end] if cached_forward is not None else fasta.fetch(chrom, start, end).upper()
55
+ for strand, dna in (("+", forward), ("-", reverse_complement(forward))):
56
+ for frame in range(3):
57
+ protein = canonical(translate(dna[frame:], table), il_equivalent)
58
+ for last, sid in automaton.iter(protein):
59
+ local = frame + 3 * (last - anchor + 1)
60
+ genomic = start + local if strand == "+" else end - local - 3 * anchor
61
+ if not start <= genomic < core_end:
62
+ continue
63
+ key = (strand, sid)
64
+ if max_seed_hits and len(positions[key]) >= max_seed_hits:
65
+ capped.add(key)
66
+ continue
67
+ position = genomic if strand == "+" else length - genomic - 3 * anchor
68
+ positions[key].append(position)
69
+ stats["novel_seed_keys_capped"] += len(capped)
70
+ LOG.info("Novel discovery: %s retained seed positions on %s; %s capped keys", sum(map(len, positions.values())), chrom, len(capped))
71
+ for key, values in positions.items():
72
+ positions[key] = array("Q", sorted(values))
73
+ for strand in ("+", "-"):
74
+ oriented = cached_forward if strand == "+" else reverse_complement(cached_forward) if cached_forward is not None else None
75
+ def fetch(a, b):
76
+ if oriented is not None:
77
+ return oriented[a:b]
78
+ if strand == "+":
79
+ return fasta.fetch(chrom, a, b).upper()
80
+ return reverse_complement(fasta.fetch(chrom, length - b, length - a).upper())
81
+ for pid in targets:
82
+ left_seed, right_seed = terminal[pid]
83
+ if (strand, left_seed) in capped or (strand, right_seed) in capped:
84
+ limited.add(pid)
85
+ lefts = positions.get((strand, left_seed), ())
86
+ rights = positions.get((strand, right_seed), ())
87
+ peptide = peptides[pid]
88
+ nt_length = len(peptide) * 3
89
+ expected = canonical(peptide, il_equivalent)
90
+ # Verify each seed's surrounding peptide context before joining
91
+ # loci. Most random 4-aa anchors cannot extend to a valid splice
92
+ # boundary. This avoids the Cartesian product of such anchors.
93
+ right_groups = defaultdict(list)
94
+ right_windows = {}
95
+ for right in rights:
96
+ end = right + 3 * anchor
97
+ window_start = end - nt_length
98
+ if window_start < 0:
99
+ continue
100
+ window = fetch(window_start, end)
101
+ protein = canonical(translate(window, table), il_equivalent)
102
+ stats["novel_seed_contexts_checked"] += 1
103
+ shared = 0
104
+ for observed, wanted in zip(reversed(protein), reversed(expected)):
105
+ if observed != wanted:
106
+ break
107
+ shared += 1
108
+ minimum_cut = max(3 * anchor, 3 * (len(peptide) - shared) - 2)
109
+ accepted = False
110
+ for cut in range(minimum_cut, nt_length - 3 * anchor + 1):
111
+ acceptor = window[cut-2:cut]
112
+ if acceptor in {"AG", "AC"}:
113
+ right_groups[(cut, acceptor)].append(end)
114
+ accepted = True
115
+ if accepted:
116
+ right_windows[end] = window
117
+ pairs = 0
118
+ exhausted = False
119
+ for start in lefts:
120
+ if start + nt_length > length:
121
+ continue
122
+ left_window = fetch(start, start + nt_length)
123
+ protein = canonical(translate(left_window, table), il_equivalent)
124
+ stats["novel_seed_contexts_checked"] += 1
125
+ shared = 0
126
+ for observed, wanted in zip(protein, expected):
127
+ if observed != wanted:
128
+ break
129
+ shared += 1
130
+ maximum_cut = min(nt_length - 3 * anchor, 3 * shared + 2)
131
+ for cut in range(3 * anchor, maximum_cut + 1):
132
+ donor_motif = left_window[cut:cut+2]
133
+ if donor_motif not in {"GT", "GC", "AT"}:
134
+ continue
135
+ acceptor_motif = "AC" if donor_motif == "AT" else "AG"
136
+ ends = right_groups.get((cut, acceptor_motif), ())
137
+ low = start + nt_length + min_intron
138
+ high = start + nt_length + max_intron
139
+ lo, hi = bisect_left(ends, low), bisect_right(ends, high)
140
+ for end in ends[lo:hi]:
141
+ if max_pairs and pairs >= max_pairs:
142
+ limited.add(pid)
143
+ stats["novel_pair_groups_capped"] += 1
144
+ exhausted = True
145
+ break
146
+ pairs += 1
147
+ stats["novel_anchor_pairs_tested"] += 1
148
+ donor = start + cut
149
+ acceptor = end - (nt_length - cut)
150
+ right_window = right_windows[end]
151
+ matched = translate(left_window[:cut] + right_window[cut:], table)
152
+ if canonical(matched, il_equivalent) != expected:
153
+ continue
154
+ if strand == "+":
155
+ blocks = ((start, donor), (acceptor, end))
156
+ else:
157
+ blocks = ((length - end, length - acceptor), (length - donor, length - start))
158
+ context = genomic_context(fasta,chrom,strand,blocks,matched,table)
159
+ context["context_evidence"] = "novel"
160
+ m = donor_motif + "-" + acceptor_motif
161
+ stats["novel_hits_before_deduplication"] += 1
162
+ yield (pid, chrom, strand, blocks, matched, "novel", 0, m, context)
163
+ if exhausted:
164
+ break
165
+ if exhausted:
166
+ break
167
+ oriented = None
168
+ cached_forward = None
169
+ LOG.info("Novel discovery: finished %s", chrom)