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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]