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,480 @@
1
+ """Bounded palindrome, inverted-repeat, tandem-repeat, and microsatellite scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import cast
7
+
8
+ from dnakit.exceptions import ConfigurationError
9
+ from dnakit.patterns._shared import (
10
+ SequenceInput,
11
+ build_result,
12
+ iupac_compatible,
13
+ pattern_provenance,
14
+ require_linear,
15
+ resolve_sequence,
16
+ segment_location,
17
+ segments,
18
+ validate_bool,
19
+ validate_positive_int,
20
+ )
21
+ from dnakit.patterns.results import (
22
+ InvertedRepeatHit,
23
+ PalindromeHit,
24
+ PatternResult,
25
+ TandemRepeatHit,
26
+ )
27
+
28
+ _COMPLEMENT = str.maketrans(
29
+ {
30
+ "A": "T",
31
+ "C": "G",
32
+ "G": "C",
33
+ "T": "A",
34
+ "R": "Y",
35
+ "Y": "R",
36
+ "S": "S",
37
+ "W": "W",
38
+ "K": "M",
39
+ "M": "K",
40
+ "B": "V",
41
+ "D": "H",
42
+ "H": "D",
43
+ "V": "B",
44
+ "N": "N",
45
+ }
46
+ )
47
+ _MAX_TANDEM_UNIT_LENGTH = 100_000
48
+
49
+
50
+ def _reverse_complement(text: str) -> str:
51
+ return text.translate(_COMPLEMENT)[::-1]
52
+
53
+
54
+ def _compatible(left: str, right: str) -> bool:
55
+ return len(left) == len(right) and all(
56
+ iupac_compatible(left_base, right_base)
57
+ for left_base, right_base in zip(left, right, strict=True)
58
+ )
59
+
60
+
61
+ def find_reverse_complement_palindromes(
62
+ value: SequenceInput,
63
+ *,
64
+ min_length: int = 4,
65
+ max_length: int = 100,
66
+ maximal_per_start: bool = True,
67
+ max_comparisons: int = 5_000_000,
68
+ max_comparison_cells: int = 50_000_000,
69
+ max_matches: int = 100_000,
70
+ ) -> PatternResult[PalindromeHit]:
71
+ """Find IUPAC-compatible reverse-complement palindromes within fragments."""
72
+
73
+ sequence, sequence_id = resolve_sequence(value)
74
+ require_linear(sequence, "reverse-complement palindrome detection")
75
+ validate_positive_int(min_length, "min_length")
76
+ validate_positive_int(max_length, "max_length")
77
+ validate_positive_int(max_comparisons, "max_comparisons")
78
+ validate_positive_int(max_comparison_cells, "max_comparison_cells")
79
+ validate_positive_int(max_matches, "max_matches")
80
+ validate_bool(maximal_per_start, "maximal_per_start")
81
+ if min_length > max_length:
82
+ raise ConfigurationError("min_length cannot exceed max_length.")
83
+
84
+ hits: list[PalindromeHit] = []
85
+ comparisons = 0
86
+ comparison_cells = 0
87
+ truncated = False
88
+ for item in segments(sequence):
89
+ for start in range(len(item.text)):
90
+ matches: list[tuple[int, str]] = []
91
+ upper = min(max_length, len(item.text) - start)
92
+ for length in range(min_length, upper + 1):
93
+ comparisons += 1
94
+ comparison_cells += length
95
+ if comparisons > max_comparisons:
96
+ raise ConfigurationError("Palindrome scan exceeded max_comparisons.")
97
+ if comparison_cells > max_comparison_cells:
98
+ raise ConfigurationError("Palindrome scan exceeded max_comparison_cells.")
99
+ candidate = item.text[start : start + length]
100
+ if _compatible(candidate, _reverse_complement(candidate)):
101
+ matches.append((length, candidate))
102
+ selected = matches[-1:] if maximal_per_start else matches
103
+ for length, candidate in selected:
104
+ if len(hits) >= max_matches:
105
+ truncated = True
106
+ break
107
+ symbol_location, coordinate_location = segment_location(item, start, start + length)
108
+ hits.append(
109
+ PalindromeHit(
110
+ sequence=candidate,
111
+ symbol_location=symbol_location,
112
+ coordinate_location=coordinate_location,
113
+ length=length,
114
+ )
115
+ )
116
+ if truncated:
117
+ break
118
+ if truncated:
119
+ break
120
+ result = build_result(
121
+ sequence,
122
+ sequence_id,
123
+ name="reverse_complement_palindrome_scan",
124
+ method="bounded_exhaustive_iupac_compatibility",
125
+ algorithm_version="1.0",
126
+ parameters={
127
+ "min_length": min_length,
128
+ "max_length": max_length,
129
+ "maximal_per_start": maximal_per_start,
130
+ "ambiguity_rule": "IUPAC-set-intersection",
131
+ "comparisons": comparisons,
132
+ "max_comparisons": max_comparisons,
133
+ "comparison_cells": comparison_cells,
134
+ "max_comparison_cells": max_comparison_cells,
135
+ },
136
+ hits=hits,
137
+ max_matches=max_matches,
138
+ truncated=truncated,
139
+ provenance=pattern_provenance(reimplementation=False),
140
+ )
141
+ return cast(PatternResult[PalindromeHit], result)
142
+
143
+
144
+ def find_inverted_repeats(
145
+ value: SequenceInput,
146
+ *,
147
+ min_arm_length: int = 4,
148
+ max_arm_length: int = 50,
149
+ min_loop_length: int = 0,
150
+ max_loop_length: int = 100,
151
+ max_comparisons: int = 5_000_000,
152
+ max_comparison_cells: int = 50_000_000,
153
+ max_matches: int = 100_000,
154
+ ) -> PatternResult[InvertedRepeatHit]:
155
+ """Find exact/IUPAC-compatible inverted-repeat arms with bounded loops."""
156
+
157
+ sequence, sequence_id = resolve_sequence(value)
158
+ require_linear(sequence, "inverted-repeat detection")
159
+ validate_positive_int(min_arm_length, "min_arm_length")
160
+ validate_positive_int(max_arm_length, "max_arm_length")
161
+ if (
162
+ isinstance(min_loop_length, bool)
163
+ or not isinstance(min_loop_length, int)
164
+ or min_loop_length < 0
165
+ ):
166
+ raise ConfigurationError("min_loop_length must be a non-negative integer.")
167
+ if (
168
+ isinstance(max_loop_length, bool)
169
+ or not isinstance(max_loop_length, int)
170
+ or max_loop_length < 0
171
+ ):
172
+ raise ConfigurationError("max_loop_length must be a non-negative integer.")
173
+ validate_positive_int(max_comparisons, "max_comparisons")
174
+ validate_positive_int(max_comparison_cells, "max_comparison_cells")
175
+ validate_positive_int(max_matches, "max_matches")
176
+ if min_arm_length > max_arm_length or min_loop_length > max_loop_length:
177
+ raise ConfigurationError("Minimum repeat bounds cannot exceed maximum bounds.")
178
+
179
+ hits: list[InvertedRepeatHit] = []
180
+ comparisons = 0
181
+ comparison_cells = 0
182
+ truncated = False
183
+ for item in segments(sequence):
184
+ text = item.text
185
+ for left_start in range(len(text)):
186
+ for arm_length in range(min_arm_length, max_arm_length + 1):
187
+ left_end = left_start + arm_length
188
+ if left_end > len(text):
189
+ break
190
+ left = text[left_start:left_end]
191
+ expected_right = _reverse_complement(left)
192
+ for loop_length in range(min_loop_length, max_loop_length + 1):
193
+ right_start = left_end + loop_length
194
+ right_end = right_start + arm_length
195
+ if right_end > len(text):
196
+ break
197
+ comparisons += 1
198
+ comparison_cells += arm_length
199
+ if comparisons > max_comparisons:
200
+ raise ConfigurationError("Inverted-repeat scan exceeded max_comparisons.")
201
+ if comparison_cells > max_comparison_cells:
202
+ raise ConfigurationError(
203
+ "Inverted-repeat scan exceeded max_comparison_cells."
204
+ )
205
+ right = text[right_start:right_end]
206
+ if not _compatible(right, expected_right):
207
+ continue
208
+ if len(hits) >= max_matches:
209
+ truncated = True
210
+ break
211
+ symbol_location, coordinate_location = segment_location(
212
+ item, left_start, right_end
213
+ )
214
+ left_location, _ = segment_location(item, left_start, left_end)
215
+ loop_location, _ = segment_location(item, left_end, right_start)
216
+ right_location, _ = segment_location(item, right_start, right_end)
217
+ hits.append(
218
+ InvertedRepeatHit(
219
+ left_arm=left,
220
+ right_arm=right,
221
+ arm_length=arm_length,
222
+ loop_length=loop_length,
223
+ symbol_location=symbol_location,
224
+ coordinate_location=coordinate_location,
225
+ left_symbol_location=left_location,
226
+ loop_symbol_location=loop_location,
227
+ right_symbol_location=right_location,
228
+ )
229
+ )
230
+ if truncated:
231
+ break
232
+ if truncated:
233
+ break
234
+ if truncated:
235
+ break
236
+ result = build_result(
237
+ sequence,
238
+ sequence_id,
239
+ name="inverted_repeat_scan",
240
+ method="bounded_exact_arm_loop_enumeration",
241
+ algorithm_version="1.0",
242
+ parameters={
243
+ "min_arm_length": min_arm_length,
244
+ "max_arm_length": max_arm_length,
245
+ "min_loop_length": min_loop_length,
246
+ "max_loop_length": max_loop_length,
247
+ "mismatches": 0,
248
+ "indels": 0,
249
+ "ambiguity_rule": "IUPAC-set-intersection",
250
+ "comparisons": comparisons,
251
+ "max_comparisons": max_comparisons,
252
+ "comparison_cells": comparison_cells,
253
+ "max_comparison_cells": max_comparison_cells,
254
+ },
255
+ hits=hits,
256
+ max_matches=max_matches,
257
+ truncated=truncated,
258
+ provenance=pattern_provenance(reimplementation=False),
259
+ )
260
+ return cast(PatternResult[InvertedRepeatHit], result)
261
+
262
+
263
+ def _repeat_thresholds(
264
+ min_unit_length: int,
265
+ max_unit_length: int,
266
+ min_repeats: int,
267
+ values: Mapping[int, int] | None,
268
+ ) -> dict[int, int]:
269
+ if values is None:
270
+ return {length: min_repeats for length in range(min_unit_length, max_unit_length + 1)}
271
+ resolved: dict[int, int] = {}
272
+ expected_count = max_unit_length - min_unit_length + 1
273
+ if len(values) != expected_count:
274
+ raise ConfigurationError(
275
+ "min_repeats_by_unit must define every accepted unit length.",
276
+ context={"expected_count": expected_count, "actual_count": len(values)},
277
+ )
278
+ for unit_length in range(min_unit_length, max_unit_length + 1):
279
+ try:
280
+ threshold = values[unit_length]
281
+ except KeyError as exc:
282
+ raise ConfigurationError(
283
+ "min_repeats_by_unit must define every accepted unit length.",
284
+ context={"missing_unit_length": unit_length},
285
+ ) from exc
286
+ validate_positive_int(threshold, f"min_repeats_by_unit[{unit_length}]")
287
+ if threshold < 2:
288
+ raise ConfigurationError("Every tandem-repeat threshold must be at least 2.")
289
+ resolved[unit_length] = threshold
290
+ return resolved
291
+
292
+
293
+ def find_tandem_repeats(
294
+ value: SequenceInput,
295
+ *,
296
+ min_unit_length: int = 1,
297
+ max_unit_length: int = 20,
298
+ min_repeats: int = 2,
299
+ min_repeats_by_unit: Mapping[int, int] | None = None,
300
+ overlapping: bool = False,
301
+ max_comparisons: int = 5_000_000,
302
+ max_comparison_cells: int = 50_000_000,
303
+ max_matches: int = 100_000,
304
+ ) -> PatternResult[TandemRepeatHit]:
305
+ """Find maximal exact repeats using the smallest accepted period at each start."""
306
+
307
+ sequence, sequence_id = resolve_sequence(value)
308
+ require_linear(sequence, "tandem-repeat detection")
309
+ validate_positive_int(min_unit_length, "min_unit_length")
310
+ validate_positive_int(max_unit_length, "max_unit_length")
311
+ validate_positive_int(min_repeats, "min_repeats")
312
+ validate_positive_int(max_comparisons, "max_comparisons")
313
+ validate_positive_int(max_comparison_cells, "max_comparison_cells")
314
+ validate_positive_int(max_matches, "max_matches")
315
+ validate_bool(overlapping, "overlapping")
316
+ if min_repeats < 2:
317
+ raise ConfigurationError("min_repeats must be at least 2.")
318
+ if min_unit_length > max_unit_length:
319
+ raise ConfigurationError("min_unit_length cannot exceed max_unit_length.")
320
+ if max_unit_length > _MAX_TANDEM_UNIT_LENGTH:
321
+ raise ConfigurationError(
322
+ "max_unit_length exceeds the hard tandem-repeat unit limit.",
323
+ code="TANDEM_REPEAT_UNIT_LIMIT",
324
+ context={"maximum": _MAX_TANDEM_UNIT_LENGTH},
325
+ )
326
+ longest_fragment = max((len(item.text) for item in segments(sequence)), default=0)
327
+ useful_maximum = longest_fragment // min_repeats
328
+ if min_unit_length > useful_maximum:
329
+ effective_max_unit_length = min_unit_length - 1
330
+ else:
331
+ effective_max_unit_length = min(max_unit_length, useful_maximum)
332
+ thresholds = _repeat_thresholds(
333
+ min_unit_length, max_unit_length, min_repeats, min_repeats_by_unit
334
+ )
335
+
336
+ hits: list[TandemRepeatHit] = []
337
+ comparisons = 0
338
+ comparison_cells = 0
339
+ truncated = False
340
+ for item in segments(sequence):
341
+ start = 0
342
+ while start < len(item.text):
343
+ found: tuple[str, int, int] | None = None
344
+ for unit_length in range(min_unit_length, effective_max_unit_length + 1):
345
+ unit_end = start + unit_length
346
+ minimum_end = start + unit_length * thresholds[unit_length]
347
+ if minimum_end > len(item.text):
348
+ continue
349
+ unit = item.text[start:unit_end]
350
+ repeat_count = 1
351
+ cursor = unit_end
352
+ while cursor + unit_length <= len(item.text):
353
+ comparisons += 1
354
+ comparison_cells += unit_length
355
+ if comparisons > max_comparisons:
356
+ raise ConfigurationError("Tandem-repeat scan exceeded max_comparisons.")
357
+ if comparison_cells > max_comparison_cells:
358
+ raise ConfigurationError(
359
+ "Tandem-repeat scan exceeded max_comparison_cells."
360
+ )
361
+ if item.text[cursor : cursor + unit_length] != unit:
362
+ break
363
+ repeat_count += 1
364
+ cursor += unit_length
365
+ if repeat_count >= thresholds[unit_length]:
366
+ found = unit, repeat_count, cursor
367
+ break
368
+ if found is None:
369
+ start += 1
370
+ continue
371
+ unit, repeat_count, end = found
372
+ if len(hits) >= max_matches:
373
+ truncated = True
374
+ break
375
+ symbol_location, coordinate_location = segment_location(item, start, end)
376
+ hits.append(
377
+ TandemRepeatHit(
378
+ unit=unit,
379
+ unit_length=len(unit),
380
+ repeat_count=repeat_count,
381
+ sequence=item.text[start:end],
382
+ symbol_location=symbol_location,
383
+ coordinate_location=coordinate_location,
384
+ )
385
+ )
386
+ start += 1 if overlapping else end - start
387
+ if truncated:
388
+ break
389
+ result = build_result(
390
+ sequence,
391
+ sequence_id,
392
+ name="tandem_repeat_scan",
393
+ method="smallest_period_exact_run",
394
+ algorithm_version="1.0",
395
+ parameters={
396
+ "min_unit_length": min_unit_length,
397
+ "max_unit_length": max_unit_length,
398
+ "effective_max_unit_length": effective_max_unit_length,
399
+ "min_repeats": min_repeats,
400
+ "min_repeats_by_unit": {
401
+ str(unit_length): threshold for unit_length, threshold in sorted(thresholds.items())
402
+ },
403
+ "overlapping": overlapping,
404
+ "mismatches": 0,
405
+ "indels": 0,
406
+ "ambiguity_rule": "literal-IUPAC-symbol-equality",
407
+ "period_policy": "smallest-accepted-at-each-start",
408
+ "comparisons": comparisons,
409
+ "max_comparisons": max_comparisons,
410
+ "comparison_cells": comparison_cells,
411
+ "max_comparison_cells": max_comparison_cells,
412
+ },
413
+ hits=hits,
414
+ max_matches=max_matches,
415
+ truncated=truncated,
416
+ provenance=pattern_provenance(reimplementation=False),
417
+ )
418
+ return cast(PatternResult[TandemRepeatHit], result)
419
+
420
+
421
+ def find_microsatellites(
422
+ value: SequenceInput,
423
+ *,
424
+ min_repeats_by_unit: Mapping[int, int] | None = None,
425
+ overlapping: bool = False,
426
+ max_comparisons: int = 5_000_000,
427
+ max_comparison_cells: int = 50_000_000,
428
+ max_matches: int = 100_000,
429
+ ) -> PatternResult[TandemRepeatHit]:
430
+ """Find exact 1--6 bp STRs with explicit per-unit repeat thresholds."""
431
+
432
+ thresholds = (
433
+ {1: 6, 2: 3, 3: 3, 4: 3, 5: 3, 6: 3}
434
+ if min_repeats_by_unit is None
435
+ else dict(min_repeats_by_unit)
436
+ )
437
+ result = find_tandem_repeats(
438
+ value,
439
+ min_unit_length=1,
440
+ max_unit_length=6,
441
+ min_repeats=2,
442
+ min_repeats_by_unit=thresholds,
443
+ overlapping=overlapping,
444
+ max_comparisons=max_comparisons,
445
+ max_comparison_cells=max_comparison_cells,
446
+ max_matches=max_matches,
447
+ )
448
+ parameters = dict(result.parameters)
449
+ parameters.update(
450
+ {
451
+ "definition": "exact-short-tandem-repeat-1-to-6-bp",
452
+ "interruptions_allowed": False,
453
+ }
454
+ )
455
+ return PatternResult(
456
+ name="microsatellite_scan",
457
+ method=result.method,
458
+ algorithm_version=result.algorithm_version,
459
+ sequence_id=result.sequence_id,
460
+ parameters=type(result.parameters)(parameters),
461
+ hits=result.hits,
462
+ inspected_symbol_count=result.inspected_symbol_count,
463
+ gap_count=result.gap_count,
464
+ unknown_gap_count=result.unknown_gap_count,
465
+ max_matches=result.max_matches,
466
+ truncated=result.truncated,
467
+ coordinate_system=result.coordinate_system,
468
+ gap_policy=result.gap_policy,
469
+ topology=result.topology,
470
+ provenance=result.provenance,
471
+ issues=result.issues,
472
+ )
473
+
474
+
475
+ __all__ = [
476
+ "find_inverted_repeats",
477
+ "find_microsatellites",
478
+ "find_reverse_complement_palindromes",
479
+ "find_tandem_repeats",
480
+ ]