dnakit 0.1.0.dev0__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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Start/stop codon and six-frame ORF annotation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from itertools import islice
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from dnakit.core import Strand
|
|
10
|
+
from dnakit.exceptions import ConfigurationError
|
|
11
|
+
from dnakit.ops import translate
|
|
12
|
+
from dnakit.patterns._shared import (
|
|
13
|
+
SequenceInput,
|
|
14
|
+
build_result,
|
|
15
|
+
coerce_strands,
|
|
16
|
+
pattern_provenance,
|
|
17
|
+
require_linear,
|
|
18
|
+
resolve_sequence,
|
|
19
|
+
reverse_complement_text,
|
|
20
|
+
segment_location,
|
|
21
|
+
segments,
|
|
22
|
+
validate_bool,
|
|
23
|
+
validate_positive_int,
|
|
24
|
+
)
|
|
25
|
+
from dnakit.patterns.results import CodonSite, ORFHit, PatternResult
|
|
26
|
+
|
|
27
|
+
_GENETIC_CODE_STARTS = {
|
|
28
|
+
1: frozenset({"ATG"}),
|
|
29
|
+
11: frozenset({"ATG", "GTG", "TTG"}),
|
|
30
|
+
}
|
|
31
|
+
_GENETIC_CODE_STOPS = {
|
|
32
|
+
1: frozenset({"TAA", "TAG", "TGA"}),
|
|
33
|
+
11: frozenset({"TAA", "TAG", "TGA"}),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _codon_set(
|
|
38
|
+
values: Iterable[str] | None,
|
|
39
|
+
*,
|
|
40
|
+
default: frozenset[str],
|
|
41
|
+
name: str,
|
|
42
|
+
) -> frozenset[str]:
|
|
43
|
+
if values is None:
|
|
44
|
+
return default
|
|
45
|
+
if isinstance(values, (str, bytes)):
|
|
46
|
+
raise ConfigurationError(f"{name} must be an iterable of codon strings.")
|
|
47
|
+
try:
|
|
48
|
+
materialized = tuple(islice(iter(values), 65))
|
|
49
|
+
except TypeError as exc:
|
|
50
|
+
raise ConfigurationError(f"{name} must be an iterable of codon strings.") from exc
|
|
51
|
+
if len(materialized) > 64:
|
|
52
|
+
raise ConfigurationError(
|
|
53
|
+
f"{name} cannot contain more than the 64 canonical triplets.",
|
|
54
|
+
code="CODON_SET_SIZE_LIMIT",
|
|
55
|
+
)
|
|
56
|
+
resolved = frozenset(materialized)
|
|
57
|
+
if not resolved:
|
|
58
|
+
raise ConfigurationError(f"{name} must contain at least one codon.")
|
|
59
|
+
if any(
|
|
60
|
+
not isinstance(codon, str) or len(codon) != 3 or set(codon) - set("ACGT")
|
|
61
|
+
for codon in resolved
|
|
62
|
+
):
|
|
63
|
+
raise ConfigurationError(f"{name} must contain uppercase canonical DNA triplets.")
|
|
64
|
+
return resolved
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _code_sets(
|
|
68
|
+
genetic_code: int,
|
|
69
|
+
start_codons: Iterable[str] | None,
|
|
70
|
+
stop_codons: Iterable[str] | None,
|
|
71
|
+
) -> tuple[frozenset[str], frozenset[str]]:
|
|
72
|
+
if (
|
|
73
|
+
isinstance(genetic_code, bool)
|
|
74
|
+
or not isinstance(genetic_code, int)
|
|
75
|
+
or genetic_code not in _GENETIC_CODE_STARTS
|
|
76
|
+
):
|
|
77
|
+
raise ConfigurationError(
|
|
78
|
+
"Only NCBI genetic codes 1 and 11 are supported for ORF boundaries.",
|
|
79
|
+
context={"genetic_code": genetic_code},
|
|
80
|
+
)
|
|
81
|
+
starts = _codon_set(
|
|
82
|
+
start_codons,
|
|
83
|
+
default=_GENETIC_CODE_STARTS[genetic_code],
|
|
84
|
+
name="start_codons",
|
|
85
|
+
)
|
|
86
|
+
stops = _codon_set(
|
|
87
|
+
stop_codons,
|
|
88
|
+
default=_GENETIC_CODE_STOPS[genetic_code],
|
|
89
|
+
name="stop_codons",
|
|
90
|
+
)
|
|
91
|
+
if starts & stops:
|
|
92
|
+
raise ConfigurationError("start_codons and stop_codons must be disjoint.")
|
|
93
|
+
return starts, stops
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _original_bounds(length: int, start: int, end: int, strand: Strand) -> tuple[int, int]:
|
|
97
|
+
if strand is Strand.FORWARD:
|
|
98
|
+
return start, end
|
|
99
|
+
return length - end, length - start
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scan_codon_sites(
|
|
103
|
+
value: SequenceInput,
|
|
104
|
+
*,
|
|
105
|
+
genetic_code: int = 1,
|
|
106
|
+
start_codons: Iterable[str] | None = None,
|
|
107
|
+
stop_codons: Iterable[str] | None = None,
|
|
108
|
+
strand: Strand | str = Strand.BOTH,
|
|
109
|
+
max_matches: int = 100_000,
|
|
110
|
+
max_codon_checks: int = 3_000_000,
|
|
111
|
+
) -> PatternResult[CodonSite]:
|
|
112
|
+
"""Locate in-frame start and stop codons in the selected reading frames."""
|
|
113
|
+
|
|
114
|
+
sequence, sequence_id = resolve_sequence(value)
|
|
115
|
+
require_linear(sequence, "codon-site scanning")
|
|
116
|
+
validate_positive_int(max_matches, "max_matches")
|
|
117
|
+
validate_positive_int(max_codon_checks, "max_codon_checks")
|
|
118
|
+
starts, stops = _code_sets(genetic_code, start_codons, stop_codons)
|
|
119
|
+
selected_strands = coerce_strands(strand)
|
|
120
|
+
estimated_checks = 2 * sequence.symbol_length * len(selected_strands)
|
|
121
|
+
if estimated_checks > 3 * max_codon_checks:
|
|
122
|
+
raise ConfigurationError(
|
|
123
|
+
"Codon scan exceeds max_codon_checks.",
|
|
124
|
+
context={"estimated_checks": estimated_checks // 3},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
hits: list[CodonSite] = []
|
|
128
|
+
checks = 0
|
|
129
|
+
truncated = False
|
|
130
|
+
for item in segments(sequence):
|
|
131
|
+
for resolved_strand in selected_strands:
|
|
132
|
+
oriented = (
|
|
133
|
+
item.text
|
|
134
|
+
if resolved_strand is Strand.FORWARD
|
|
135
|
+
else reverse_complement_text(item.text)
|
|
136
|
+
)
|
|
137
|
+
for offset in range(3):
|
|
138
|
+
signed_frame = offset + 1 if resolved_strand is Strand.FORWARD else -(offset + 1)
|
|
139
|
+
for position in range(offset, len(oriented) - 2, 3):
|
|
140
|
+
checks += 1
|
|
141
|
+
if checks > max_codon_checks:
|
|
142
|
+
raise ConfigurationError("Codon scan exceeded max_codon_checks.")
|
|
143
|
+
codon = oriented[position : position + 3]
|
|
144
|
+
kind = "start" if codon in starts else "stop" if codon in stops else None
|
|
145
|
+
if kind is None:
|
|
146
|
+
continue
|
|
147
|
+
if len(hits) >= max_matches:
|
|
148
|
+
truncated = True
|
|
149
|
+
break
|
|
150
|
+
original_start, original_end = _original_bounds(
|
|
151
|
+
len(item.text), position, position + 3, resolved_strand
|
|
152
|
+
)
|
|
153
|
+
symbol_location, coordinate_location = segment_location(
|
|
154
|
+
item, original_start, original_end
|
|
155
|
+
)
|
|
156
|
+
hits.append(
|
|
157
|
+
CodonSite(
|
|
158
|
+
kind=kind,
|
|
159
|
+
codon=codon,
|
|
160
|
+
strand=resolved_strand,
|
|
161
|
+
frame=signed_frame,
|
|
162
|
+
symbol_location=symbol_location,
|
|
163
|
+
coordinate_location=coordinate_location,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
if truncated:
|
|
167
|
+
break
|
|
168
|
+
if truncated:
|
|
169
|
+
break
|
|
170
|
+
if truncated:
|
|
171
|
+
break
|
|
172
|
+
result = build_result(
|
|
173
|
+
sequence,
|
|
174
|
+
sequence_id,
|
|
175
|
+
name="codon_site_scan",
|
|
176
|
+
method="six_frame_triplet_scan",
|
|
177
|
+
algorithm_version="1.0",
|
|
178
|
+
parameters={
|
|
179
|
+
"genetic_code": genetic_code,
|
|
180
|
+
"start_codons": sorted(starts),
|
|
181
|
+
"stop_codons": sorted(stops),
|
|
182
|
+
"strand": str(strand),
|
|
183
|
+
"codon_checks": checks,
|
|
184
|
+
"max_codon_checks": max_codon_checks,
|
|
185
|
+
},
|
|
186
|
+
hits=hits,
|
|
187
|
+
max_matches=max_matches,
|
|
188
|
+
truncated=truncated,
|
|
189
|
+
provenance=pattern_provenance(
|
|
190
|
+
reimplementation=True,
|
|
191
|
+
reference_name="NCBI genetic code",
|
|
192
|
+
reference_version=str(genetic_code),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
return cast(PatternResult[CodonSite], result)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def scan_orfs(
|
|
199
|
+
value: SequenceInput,
|
|
200
|
+
*,
|
|
201
|
+
genetic_code: int = 1,
|
|
202
|
+
start_codons: Iterable[str] | None = None,
|
|
203
|
+
stop_codons: Iterable[str] | None = None,
|
|
204
|
+
min_length: int = 0,
|
|
205
|
+
require_complete: bool = True,
|
|
206
|
+
strand: Strand | str = Strand.BOTH,
|
|
207
|
+
max_matches: int = 100_000,
|
|
208
|
+
max_codon_checks: int = 3_000_000,
|
|
209
|
+
) -> PatternResult[ORFHit]:
|
|
210
|
+
"""Find start-anchored ORFs on up to six frames.
|
|
211
|
+
|
|
212
|
+
Every in-frame start is retained until the next stop, so nested starts yield
|
|
213
|
+
distinct ORFs. In incomplete mode, an unclosed start extends to the final
|
|
214
|
+
complete codon of its gap-delimited fragment. Circular inputs must first be
|
|
215
|
+
linearized at a documented origin.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
sequence, sequence_id = resolve_sequence(value)
|
|
219
|
+
require_linear(sequence, "ORF scanning")
|
|
220
|
+
if isinstance(min_length, bool) or not isinstance(min_length, int) or min_length < 0:
|
|
221
|
+
raise ConfigurationError("min_length must be a non-negative integer.")
|
|
222
|
+
validate_bool(require_complete, "require_complete")
|
|
223
|
+
validate_positive_int(max_matches, "max_matches")
|
|
224
|
+
validate_positive_int(max_codon_checks, "max_codon_checks")
|
|
225
|
+
starts, stops = _code_sets(genetic_code, start_codons, stop_codons)
|
|
226
|
+
selected_strands = coerce_strands(strand)
|
|
227
|
+
|
|
228
|
+
hits: list[ORFHit] = []
|
|
229
|
+
checks = 0
|
|
230
|
+
truncated = False
|
|
231
|
+
|
|
232
|
+
def add_orf(
|
|
233
|
+
item: object,
|
|
234
|
+
resolved_strand: Strand,
|
|
235
|
+
signed_frame: int,
|
|
236
|
+
oriented: str,
|
|
237
|
+
start: int,
|
|
238
|
+
end: int,
|
|
239
|
+
stop_codon: str | None,
|
|
240
|
+
) -> bool:
|
|
241
|
+
from dnakit.patterns._shared import Segment
|
|
242
|
+
|
|
243
|
+
if not isinstance(item, Segment):
|
|
244
|
+
raise AssertionError("Internal segment type mismatch.")
|
|
245
|
+
nucleotide_length = end - start
|
|
246
|
+
if nucleotide_length < min_length:
|
|
247
|
+
return True
|
|
248
|
+
if len(hits) >= max_matches:
|
|
249
|
+
return False
|
|
250
|
+
original_start, original_end = _original_bounds(len(item.text), start, end, resolved_strand)
|
|
251
|
+
symbol_location, coordinate_location = segment_location(item, original_start, original_end)
|
|
252
|
+
coding = oriented[start:end]
|
|
253
|
+
translation_table = 1
|
|
254
|
+
protein = translate(coding, table=translation_table)
|
|
255
|
+
if genetic_code == 11 and protein and coding[:3] in starts:
|
|
256
|
+
protein = "M" + protein[1:]
|
|
257
|
+
hits.append(
|
|
258
|
+
ORFHit(
|
|
259
|
+
strand=resolved_strand,
|
|
260
|
+
frame=signed_frame,
|
|
261
|
+
symbol_location=symbol_location,
|
|
262
|
+
coordinate_location=coordinate_location,
|
|
263
|
+
nucleotide_length=nucleotide_length,
|
|
264
|
+
codon_count=nucleotide_length // 3,
|
|
265
|
+
start_codon=oriented[start : start + 3],
|
|
266
|
+
stop_codon=stop_codon,
|
|
267
|
+
complete=stop_codon is not None,
|
|
268
|
+
translation=protein,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
for item in segments(sequence):
|
|
274
|
+
for resolved_strand in selected_strands:
|
|
275
|
+
oriented = (
|
|
276
|
+
item.text
|
|
277
|
+
if resolved_strand is Strand.FORWARD
|
|
278
|
+
else reverse_complement_text(item.text)
|
|
279
|
+
)
|
|
280
|
+
for offset in range(3):
|
|
281
|
+
signed_frame = offset + 1 if resolved_strand is Strand.FORWARD else -(offset + 1)
|
|
282
|
+
open_starts: list[int] = []
|
|
283
|
+
coding_end = offset + ((len(oriented) - offset) // 3) * 3
|
|
284
|
+
for position in range(offset, coding_end, 3):
|
|
285
|
+
checks += 1
|
|
286
|
+
if checks > max_codon_checks:
|
|
287
|
+
raise ConfigurationError("ORF scan exceeded max_codon_checks.")
|
|
288
|
+
codon = oriented[position : position + 3]
|
|
289
|
+
if codon in starts:
|
|
290
|
+
open_starts.append(position)
|
|
291
|
+
if codon not in stops:
|
|
292
|
+
continue
|
|
293
|
+
for start in open_starts:
|
|
294
|
+
if not add_orf(
|
|
295
|
+
item,
|
|
296
|
+
resolved_strand,
|
|
297
|
+
signed_frame,
|
|
298
|
+
oriented,
|
|
299
|
+
start,
|
|
300
|
+
position + 3,
|
|
301
|
+
codon,
|
|
302
|
+
):
|
|
303
|
+
truncated = True
|
|
304
|
+
break
|
|
305
|
+
open_starts.clear()
|
|
306
|
+
if truncated:
|
|
307
|
+
break
|
|
308
|
+
if not truncated and not require_complete:
|
|
309
|
+
for start in open_starts:
|
|
310
|
+
if not add_orf(
|
|
311
|
+
item,
|
|
312
|
+
resolved_strand,
|
|
313
|
+
signed_frame,
|
|
314
|
+
oriented,
|
|
315
|
+
start,
|
|
316
|
+
coding_end,
|
|
317
|
+
None,
|
|
318
|
+
):
|
|
319
|
+
truncated = True
|
|
320
|
+
break
|
|
321
|
+
if truncated:
|
|
322
|
+
break
|
|
323
|
+
if truncated:
|
|
324
|
+
break
|
|
325
|
+
if truncated:
|
|
326
|
+
break
|
|
327
|
+
result = build_result(
|
|
328
|
+
sequence,
|
|
329
|
+
sequence_id,
|
|
330
|
+
name="orf_scan",
|
|
331
|
+
method="start_to_next_in_frame_stop",
|
|
332
|
+
algorithm_version="1.0",
|
|
333
|
+
parameters={
|
|
334
|
+
"genetic_code": genetic_code,
|
|
335
|
+
"translation_table": 1,
|
|
336
|
+
"start_codons": sorted(starts),
|
|
337
|
+
"stop_codons": sorted(stops),
|
|
338
|
+
"min_length": min_length,
|
|
339
|
+
"minimum_length_includes_terminal_stop": True,
|
|
340
|
+
"require_complete": require_complete,
|
|
341
|
+
"strand": str(strand),
|
|
342
|
+
"nested_starts": "emit_each",
|
|
343
|
+
"frame_origin": "each-gap-delimited-fragment",
|
|
344
|
+
"terminal_stop_in_translation": True,
|
|
345
|
+
"codon_checks": checks,
|
|
346
|
+
"max_codon_checks": max_codon_checks,
|
|
347
|
+
},
|
|
348
|
+
hits=hits,
|
|
349
|
+
max_matches=max_matches,
|
|
350
|
+
truncated=truncated,
|
|
351
|
+
provenance=pattern_provenance(
|
|
352
|
+
reimplementation=True,
|
|
353
|
+
reference_name="NCBI genetic code and ORF boundary rules",
|
|
354
|
+
reference_version=str(genetic_code),
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
return cast(PatternResult[ORFHit], result)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
__all__ = ["scan_codon_sites", "scan_orfs"]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Rule-based CRISPR PAM and adjacent-guide candidate scanning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from itertools import islice
|
|
9
|
+
from typing import Literal, cast
|
|
10
|
+
|
|
11
|
+
from dnakit.core import Location, Strand
|
|
12
|
+
from dnakit.exceptions import ConfigurationError
|
|
13
|
+
from dnakit.patterns._shared import (
|
|
14
|
+
SequenceInput,
|
|
15
|
+
build_result,
|
|
16
|
+
circular_location,
|
|
17
|
+
coerce_strands,
|
|
18
|
+
iupac_compatible,
|
|
19
|
+
pattern_provenance,
|
|
20
|
+
require_ungapped_circular,
|
|
21
|
+
resolve_sequence,
|
|
22
|
+
reverse_complement_text,
|
|
23
|
+
segment_location,
|
|
24
|
+
segments,
|
|
25
|
+
validate_bool,
|
|
26
|
+
validate_iupac_text,
|
|
27
|
+
validate_positive_int,
|
|
28
|
+
)
|
|
29
|
+
from dnakit.patterns.results import GuideCandidate, PatternResult
|
|
30
|
+
|
|
31
|
+
PAMSide = Literal["3prime", "5prime"]
|
|
32
|
+
PAM_CATALOG_VERSION = "common-nucleases-v1"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, init=False)
|
|
36
|
+
class PAMRule:
|
|
37
|
+
"""A nuclease name, IUPAC PAM, guide orientation, and default guide length."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
pam: str
|
|
41
|
+
pam_side: PAMSide
|
|
42
|
+
guide_length: int
|
|
43
|
+
source: str
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
name: str,
|
|
48
|
+
pam: str,
|
|
49
|
+
pam_side: PAMSide,
|
|
50
|
+
guide_length: int,
|
|
51
|
+
*,
|
|
52
|
+
source: str = "user-provided",
|
|
53
|
+
) -> None:
|
|
54
|
+
if not isinstance(name, str) or not name.strip():
|
|
55
|
+
raise ConfigurationError("PAM rule name must be non-empty.")
|
|
56
|
+
resolved_pam = validate_iupac_text(pam, "pam")
|
|
57
|
+
if pam_side not in ("3prime", "5prime"):
|
|
58
|
+
raise ConfigurationError("pam_side must be '3prime' or '5prime'.")
|
|
59
|
+
validate_positive_int(guide_length, "guide_length")
|
|
60
|
+
if not isinstance(source, str) or not source.strip():
|
|
61
|
+
raise ConfigurationError("PAM rule source must be non-empty.")
|
|
62
|
+
object.__setattr__(self, "name", name)
|
|
63
|
+
object.__setattr__(self, "pam", resolved_pam)
|
|
64
|
+
object.__setattr__(self, "pam_side", pam_side)
|
|
65
|
+
object.__setattr__(self, "guide_length", guide_length)
|
|
66
|
+
object.__setattr__(self, "source", source)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_SOURCE = "DNAKit common PAM boundary catalog; verify experimental design against primary sources"
|
|
70
|
+
BUILTIN_PAM_RULES: Mapping[str, PAMRule] = {
|
|
71
|
+
rule.name: rule
|
|
72
|
+
for rule in (
|
|
73
|
+
PAMRule("SpCas9", "NGG", "3prime", 20, source=_SOURCE),
|
|
74
|
+
PAMRule("SaCas9", "NNGRRT", "3prime", 21, source=_SOURCE),
|
|
75
|
+
PAMRule("AsCas12a", "TTTV", "5prime", 20, source=_SOURCE),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _resolve_rule(rule: str | PAMRule) -> PAMRule:
|
|
81
|
+
if isinstance(rule, PAMRule):
|
|
82
|
+
return rule
|
|
83
|
+
if isinstance(rule, str):
|
|
84
|
+
try:
|
|
85
|
+
return BUILTIN_PAM_RULES[rule]
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
"Unknown built-in PAM rule.",
|
|
89
|
+
context={"rule": rule, "available": sorted(BUILTIN_PAM_RULES)},
|
|
90
|
+
hint="Pass a PAMRule for another nuclease.",
|
|
91
|
+
) from exc
|
|
92
|
+
raise ConfigurationError("rule must be a built-in name or PAMRule.")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _slice_circular(text: str, start: int, length: int) -> str:
|
|
96
|
+
if length > len(text):
|
|
97
|
+
return ""
|
|
98
|
+
start %= len(text)
|
|
99
|
+
doubled = text + text
|
|
100
|
+
return doubled[start : start + length]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _location(
|
|
104
|
+
*,
|
|
105
|
+
item: object,
|
|
106
|
+
oriented_start: int,
|
|
107
|
+
length: int,
|
|
108
|
+
strand: Strand,
|
|
109
|
+
circular: bool,
|
|
110
|
+
) -> tuple[Location, Location, bool]:
|
|
111
|
+
from dnakit.patterns._shared import Segment
|
|
112
|
+
|
|
113
|
+
if not isinstance(item, Segment):
|
|
114
|
+
raise AssertionError("Internal segment type mismatch.")
|
|
115
|
+
original_start = (
|
|
116
|
+
oriented_start if strand is Strand.FORWARD else len(item.text) - (oriented_start + length)
|
|
117
|
+
)
|
|
118
|
+
if circular:
|
|
119
|
+
return circular_location(original_start, length, len(item.text))
|
|
120
|
+
symbol_location, coordinate_location = segment_location(
|
|
121
|
+
item, original_start, original_start + length
|
|
122
|
+
)
|
|
123
|
+
return symbol_location, coordinate_location, False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _contains_excluded(guide: str, motifs: tuple[str, ...]) -> bool:
|
|
127
|
+
for motif in motifs:
|
|
128
|
+
for start in range(len(guide) - len(motif) + 1):
|
|
129
|
+
if all(
|
|
130
|
+
iupac_compatible(guide[start + offset], symbol)
|
|
131
|
+
for offset, symbol in enumerate(motif)
|
|
132
|
+
):
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def scan_pam_candidates(
|
|
138
|
+
value: SequenceInput,
|
|
139
|
+
rule: str | PAMRule,
|
|
140
|
+
*,
|
|
141
|
+
guide_length: int | None = None,
|
|
142
|
+
strand: Strand | str = Strand.BOTH,
|
|
143
|
+
min_gc: float = 0.0,
|
|
144
|
+
max_gc: float = 1.0,
|
|
145
|
+
exclude_motifs: Iterable[str] = (),
|
|
146
|
+
allow_ambiguous_guides: bool = False,
|
|
147
|
+
max_matches: int = 100_000,
|
|
148
|
+
max_scan_length: int = 1_000_000,
|
|
149
|
+
max_pam_length: int = 1_000,
|
|
150
|
+
max_scan_cells: int = 50_000_000,
|
|
151
|
+
max_exclude_motifs: int = 1_000,
|
|
152
|
+
max_filter_cells: int = 50_000_000,
|
|
153
|
+
) -> PatternResult[GuideCandidate]:
|
|
154
|
+
"""Find PAM-adjacent guide candidates without efficiency/off-target prediction."""
|
|
155
|
+
|
|
156
|
+
sequence, sequence_id = resolve_sequence(value)
|
|
157
|
+
definition = _resolve_rule(rule)
|
|
158
|
+
resolved_guide_length = definition.guide_length if guide_length is None else guide_length
|
|
159
|
+
validate_positive_int(resolved_guide_length, "guide_length")
|
|
160
|
+
validate_positive_int(max_matches, "max_matches")
|
|
161
|
+
validate_positive_int(max_scan_length, "max_scan_length")
|
|
162
|
+
validate_positive_int(max_pam_length, "max_pam_length")
|
|
163
|
+
validate_positive_int(max_scan_cells, "max_scan_cells")
|
|
164
|
+
validate_positive_int(max_exclude_motifs, "max_exclude_motifs")
|
|
165
|
+
validate_positive_int(max_filter_cells, "max_filter_cells")
|
|
166
|
+
validate_bool(allow_ambiguous_guides, "allow_ambiguous_guides")
|
|
167
|
+
if sequence.symbol_length > max_scan_length:
|
|
168
|
+
raise ConfigurationError("Sequence exceeds max_scan_length.")
|
|
169
|
+
if len(definition.pam) > max_pam_length:
|
|
170
|
+
raise ConfigurationError("PAM definition exceeds max_pam_length.")
|
|
171
|
+
for value_, name in ((min_gc, "min_gc"), (max_gc, "max_gc")):
|
|
172
|
+
if (
|
|
173
|
+
isinstance(value_, bool)
|
|
174
|
+
or not isinstance(value_, (int, float))
|
|
175
|
+
or not math.isfinite(value_)
|
|
176
|
+
or value_ < 0
|
|
177
|
+
or value_ > 1
|
|
178
|
+
):
|
|
179
|
+
raise ConfigurationError(f"{name} must be finite and between 0 and 1.")
|
|
180
|
+
if min_gc > max_gc:
|
|
181
|
+
raise ConfigurationError("min_gc cannot exceed max_gc.")
|
|
182
|
+
if isinstance(exclude_motifs, (str, bytes)):
|
|
183
|
+
raise ConfigurationError("exclude_motifs must be an iterable of IUPAC motifs.")
|
|
184
|
+
try:
|
|
185
|
+
raw_excluded = tuple(islice(iter(exclude_motifs), max_exclude_motifs + 1))
|
|
186
|
+
except TypeError as exc:
|
|
187
|
+
raise ConfigurationError("exclude_motifs must be an iterable of IUPAC motifs.") from exc
|
|
188
|
+
if len(raw_excluded) > max_exclude_motifs:
|
|
189
|
+
raise ConfigurationError("exclude_motifs exceeds max_exclude_motifs.")
|
|
190
|
+
excluded = tuple(validate_iupac_text(motif, "exclude_motif") for motif in raw_excluded)
|
|
191
|
+
circular = sequence.topology.value == "circular"
|
|
192
|
+
require_ungapped_circular(sequence, "CRISPR PAM scanning")
|
|
193
|
+
total_footprint = resolved_guide_length + len(definition.pam)
|
|
194
|
+
selected_strands = coerce_strands(strand)
|
|
195
|
+
estimated_cells = sequence.symbol_length * len(definition.pam) * len(selected_strands)
|
|
196
|
+
if estimated_cells > max_scan_cells:
|
|
197
|
+
raise ConfigurationError(
|
|
198
|
+
"CRISPR PAM scan exceeds max_scan_cells.",
|
|
199
|
+
context={"estimated_cells": estimated_cells, "max_scan_cells": max_scan_cells},
|
|
200
|
+
)
|
|
201
|
+
estimated_filter_cells = (
|
|
202
|
+
sequence.symbol_length
|
|
203
|
+
* len(selected_strands)
|
|
204
|
+
* sum(max(0, resolved_guide_length - len(motif) + 1) * len(motif) for motif in excluded)
|
|
205
|
+
)
|
|
206
|
+
if estimated_filter_cells > max_filter_cells:
|
|
207
|
+
raise ConfigurationError(
|
|
208
|
+
"CRISPR guide filters exceed max_filter_cells.",
|
|
209
|
+
context={
|
|
210
|
+
"estimated_filter_cells": estimated_filter_cells,
|
|
211
|
+
"max_filter_cells": max_filter_cells,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
hits: list[GuideCandidate] = []
|
|
216
|
+
truncated = False
|
|
217
|
+
for item in segments(sequence):
|
|
218
|
+
if circular and total_footprint > len(item.text):
|
|
219
|
+
continue
|
|
220
|
+
for resolved_strand in selected_strands:
|
|
221
|
+
oriented = (
|
|
222
|
+
item.text
|
|
223
|
+
if resolved_strand is Strand.FORWARD
|
|
224
|
+
else reverse_complement_text(item.text)
|
|
225
|
+
)
|
|
226
|
+
pam_length = len(definition.pam)
|
|
227
|
+
searchable = oriented + oriented[: pam_length - 1] if circular else oriented
|
|
228
|
+
limit = len(oriented) if circular else len(oriented) - pam_length + 1
|
|
229
|
+
for pam_start in range(max(0, limit)):
|
|
230
|
+
pam_sequence = searchable[pam_start : pam_start + pam_length]
|
|
231
|
+
if not all(
|
|
232
|
+
iupac_compatible(target, pattern)
|
|
233
|
+
for target, pattern in zip(pam_sequence, definition.pam, strict=True)
|
|
234
|
+
):
|
|
235
|
+
continue
|
|
236
|
+
guide_start = (
|
|
237
|
+
pam_start - resolved_guide_length
|
|
238
|
+
if definition.pam_side == "3prime"
|
|
239
|
+
else pam_start + pam_length
|
|
240
|
+
)
|
|
241
|
+
if not circular and (
|
|
242
|
+
guide_start < 0 or guide_start + resolved_guide_length > len(oriented)
|
|
243
|
+
):
|
|
244
|
+
continue
|
|
245
|
+
guide_sequence = (
|
|
246
|
+
_slice_circular(oriented, guide_start, resolved_guide_length)
|
|
247
|
+
if circular
|
|
248
|
+
else oriented[guide_start : guide_start + resolved_guide_length]
|
|
249
|
+
)
|
|
250
|
+
if not allow_ambiguous_guides and set(guide_sequence) - set("ACGT"):
|
|
251
|
+
continue
|
|
252
|
+
gc_fraction = sum(base in "GC" for base in guide_sequence) / len(guide_sequence)
|
|
253
|
+
if not min_gc <= gc_fraction <= max_gc:
|
|
254
|
+
continue
|
|
255
|
+
if _contains_excluded(guide_sequence, excluded):
|
|
256
|
+
continue
|
|
257
|
+
if len(hits) >= max_matches:
|
|
258
|
+
truncated = True
|
|
259
|
+
break
|
|
260
|
+
guide_symbol, guide_coordinate, guide_wrap = _location(
|
|
261
|
+
item=item,
|
|
262
|
+
oriented_start=guide_start % len(oriented),
|
|
263
|
+
length=resolved_guide_length,
|
|
264
|
+
strand=resolved_strand,
|
|
265
|
+
circular=circular,
|
|
266
|
+
)
|
|
267
|
+
pam_symbol, pam_coordinate, pam_wrap = _location(
|
|
268
|
+
item=item,
|
|
269
|
+
oriented_start=pam_start,
|
|
270
|
+
length=pam_length,
|
|
271
|
+
strand=resolved_strand,
|
|
272
|
+
circular=circular,
|
|
273
|
+
)
|
|
274
|
+
hits.append(
|
|
275
|
+
GuideCandidate(
|
|
276
|
+
nuclease=definition.name,
|
|
277
|
+
strand=resolved_strand,
|
|
278
|
+
guide_sequence=guide_sequence,
|
|
279
|
+
pam_sequence=pam_sequence,
|
|
280
|
+
guide_symbol_location=guide_symbol,
|
|
281
|
+
pam_symbol_location=pam_symbol,
|
|
282
|
+
guide_coordinate_location=guide_coordinate,
|
|
283
|
+
pam_coordinate_location=pam_coordinate,
|
|
284
|
+
gc_fraction=gc_fraction,
|
|
285
|
+
wraps_origin=guide_wrap or pam_wrap,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
if truncated:
|
|
289
|
+
break
|
|
290
|
+
if truncated:
|
|
291
|
+
break
|
|
292
|
+
result = build_result(
|
|
293
|
+
sequence,
|
|
294
|
+
sequence_id,
|
|
295
|
+
name="crispr_pam_candidate_scan",
|
|
296
|
+
method="iupac_pam_with_adjacent_guide",
|
|
297
|
+
algorithm_version="1.0",
|
|
298
|
+
parameters={
|
|
299
|
+
"nuclease": definition.name,
|
|
300
|
+
"pam": definition.pam,
|
|
301
|
+
"pam_side": definition.pam_side,
|
|
302
|
+
"guide_length": resolved_guide_length,
|
|
303
|
+
"strand": str(strand),
|
|
304
|
+
"min_gc": float(min_gc),
|
|
305
|
+
"max_gc": float(max_gc),
|
|
306
|
+
"exclude_motifs": excluded,
|
|
307
|
+
"allow_ambiguous_guides": allow_ambiguous_guides,
|
|
308
|
+
"source": definition.source,
|
|
309
|
+
"catalog_version": PAM_CATALOG_VERSION,
|
|
310
|
+
"efficiency_prediction": False,
|
|
311
|
+
"off_target_prediction": False,
|
|
312
|
+
"max_scan_length": max_scan_length,
|
|
313
|
+
"max_pam_length": max_pam_length,
|
|
314
|
+
"estimated_scan_cells": estimated_cells,
|
|
315
|
+
"max_scan_cells": max_scan_cells,
|
|
316
|
+
"max_exclude_motifs": max_exclude_motifs,
|
|
317
|
+
"estimated_filter_cells": estimated_filter_cells,
|
|
318
|
+
"max_filter_cells": max_filter_cells,
|
|
319
|
+
},
|
|
320
|
+
hits=hits,
|
|
321
|
+
max_matches=max_matches,
|
|
322
|
+
truncated=truncated,
|
|
323
|
+
provenance=pattern_provenance(
|
|
324
|
+
reimplementation=True,
|
|
325
|
+
reference_name="DNAKit common PAM rule definitions",
|
|
326
|
+
reference_version=PAM_CATALOG_VERSION,
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
return cast(PatternResult[GuideCandidate], result)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
__all__ = [
|
|
333
|
+
"BUILTIN_PAM_RULES",
|
|
334
|
+
"PAM_CATALOG_VERSION",
|
|
335
|
+
"PAMRule",
|
|
336
|
+
"scan_pam_candidates",
|
|
337
|
+
]
|