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,721 @@
1
+ """Bounded exact, IUPAC, regular-expression, PWM, promoter, and TF motif scans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections.abc import Iterable, Iterator, Mapping
8
+ from dataclasses import dataclass
9
+ from itertools import islice
10
+ from typing import Literal, cast
11
+
12
+ from dnakit.core import Location, Strand
13
+ from dnakit.core._json import to_json_compatible
14
+ from dnakit.exceptions import ConfigurationError, SequenceError
15
+ from dnakit.patterns._shared import (
16
+ SequenceInput,
17
+ build_result,
18
+ circular_location,
19
+ coerce_strands,
20
+ iupac_compatible,
21
+ pattern_provenance,
22
+ require_ungapped_circular,
23
+ resolve_sequence,
24
+ reverse_complement_text,
25
+ segment_location,
26
+ segments,
27
+ validate_bool,
28
+ validate_iupac_text,
29
+ validate_positive_int,
30
+ )
31
+ from dnakit.patterns.results import MotifHit, PatternResult, validate_finite_score
32
+
33
+ MotifMode = Literal["exact", "iupac", "regex"]
34
+
35
+ PROMOTER_MOTIFS: Mapping[str, str] = {
36
+ "eukaryotic_TATA_box_consensus": "TATAWAWR",
37
+ "bacterial_minus_10_consensus": "TATAAT",
38
+ "bacterial_minus_35_consensus": "TTGACA",
39
+ }
40
+ PROMOTER_CATALOG_VERSION = "builtin-boundary-v1"
41
+ MAX_PWM_LENGTH = 100_000
42
+
43
+
44
+ @dataclass(frozen=True, init=False)
45
+ class PWM:
46
+ """A position-weight input matrix normalized independently by column."""
47
+
48
+ name: str
49
+ a: tuple[float, ...]
50
+ c: tuple[float, ...]
51
+ g: tuple[float, ...]
52
+ t: tuple[float, ...]
53
+
54
+ def __init__(self, name: str, matrix: Mapping[str, Iterable[float]]) -> None:
55
+ if not isinstance(name, str) or not name.strip():
56
+ raise ConfigurationError("PWM name must be a non-empty string.")
57
+ if not isinstance(matrix, Mapping) or set(matrix) != set("ACGT"):
58
+ raise ConfigurationError("PWM matrix must contain exactly A, C, G, and T rows.")
59
+ rows: dict[str, tuple[float, ...]] = {}
60
+ for base in "ACGT":
61
+ raw = matrix[base]
62
+ if isinstance(raw, (str, bytes)):
63
+ raise ConfigurationError("PWM rows must be numerical sequences.")
64
+ try:
65
+ raw_values = tuple(islice(iter(raw), MAX_PWM_LENGTH + 1))
66
+ except TypeError as exc:
67
+ raise ConfigurationError("PWM rows must be numerical sequences.") from exc
68
+ if len(raw_values) > MAX_PWM_LENGTH:
69
+ raise ConfigurationError(
70
+ "PWM row exceeds the supported length limit.",
71
+ code="PWM_LENGTH_LIMIT",
72
+ context={"max_pwm_length": MAX_PWM_LENGTH},
73
+ )
74
+ if any(
75
+ isinstance(value, bool) or not isinstance(value, (int, float))
76
+ for value in raw_values
77
+ ):
78
+ raise ConfigurationError("PWM rows must contain real numbers, not booleans.")
79
+ try:
80
+ values = tuple(float(value) for value in raw_values)
81
+ except (TypeError, ValueError, OverflowError) as exc:
82
+ raise ConfigurationError("PWM rows must contain numbers.") from exc
83
+ if not values or any(not math.isfinite(value) or value < 0 for value in values):
84
+ raise ConfigurationError("PWM values must be finite non-negative numbers.")
85
+ rows[base] = values
86
+ lengths = {len(row) for row in rows.values()}
87
+ if len(lengths) != 1:
88
+ raise ConfigurationError("All PWM rows must have the same non-zero length.")
89
+ for column in range(len(rows["A"])):
90
+ if math.fsum(rows[base][column] for base in "ACGT") <= 0:
91
+ raise ConfigurationError("Every PWM column must have positive total weight.")
92
+ object.__setattr__(self, "name", name)
93
+ object.__setattr__(self, "a", rows["A"])
94
+ object.__setattr__(self, "c", rows["C"])
95
+ object.__setattr__(self, "g", rows["G"])
96
+ object.__setattr__(self, "t", rows["T"])
97
+
98
+ @property
99
+ def length(self) -> int:
100
+ return len(self.a)
101
+
102
+ def row(self, base: str) -> tuple[float, ...]:
103
+ if base == "A":
104
+ return self.a
105
+ if base == "C":
106
+ return self.c
107
+ if base == "G":
108
+ return self.g
109
+ if base == "T":
110
+ return self.t
111
+ raise ConfigurationError("PWM scores are defined only for canonical DNA bases.")
112
+
113
+ def to_dict(self) -> dict[str, object]:
114
+ return cast(dict[str, object], to_json_compatible(self))
115
+
116
+
117
+ def _fixed_matches(
118
+ text: str,
119
+ motif: str,
120
+ *,
121
+ mode: Literal["exact", "iupac"],
122
+ overlapping: bool,
123
+ circular: bool,
124
+ ) -> Iterator[tuple[int, int, str]]:
125
+ length = len(motif)
126
+ if circular and length > len(text):
127
+ raise ConfigurationError("A circular motif cannot be longer than the sequence.")
128
+ searchable = text + text[: length - 1] if circular and length > 1 else text
129
+ step = 1 if overlapping else length
130
+ start = 0
131
+ limit = len(text) if circular else len(text) - length + 1
132
+ while start < max(0, limit):
133
+ candidate = searchable[start : start + length]
134
+ matched = candidate == motif
135
+ if mode == "iupac":
136
+ matched = len(candidate) == length and all(
137
+ iupac_compatible(left, right) for left, right in zip(candidate, motif, strict=True)
138
+ )
139
+ if matched:
140
+ yield start, start + length, candidate
141
+ start += step
142
+ else:
143
+ start += 1
144
+
145
+
146
+ def _regex_matches(
147
+ text: str,
148
+ pattern: str,
149
+ *,
150
+ overlapping: bool,
151
+ ) -> Iterator[tuple[int, int, str]]:
152
+ try:
153
+ compiled = re.compile(pattern)
154
+ except (re.error, OverflowError) as exc:
155
+ raise ConfigurationError(
156
+ "Invalid regular-expression motif.", context={"regex_error": str(exc)}
157
+ ) from exc
158
+ position = 0
159
+ while position <= len(text):
160
+ match = compiled.search(text, position)
161
+ if match is None:
162
+ return
163
+ if match.end() == match.start():
164
+ raise ConfigurationError("Regular-expression motifs must not produce empty matches.")
165
+ yield match.start(), match.end(), match.group(0)
166
+ position = match.start() + 1 if overlapping else match.end()
167
+
168
+
169
+ def _validate_safe_regex(pattern: str, scan_length: int) -> int:
170
+ """Reject constructs with unbounded backtracking or non-DNA semantics."""
171
+
172
+ allowed = frozenset("ACGTRYSWKMBDHVNacgtryswkmbdhvn.[]^-?*+{},0123456789$")
173
+ invalid = sorted(set(pattern) - allowed)
174
+ if invalid or any(token in pattern for token in ("(", ")", "|", "\\")):
175
+ raise ConfigurationError(
176
+ "Regex motif is outside DNAKit's safe DNA-regex subset.",
177
+ context={"invalid_characters": invalid},
178
+ hint="Use literals, dot, character classes, anchors, and simple quantifiers only.",
179
+ )
180
+ if re.search(r"(?:[*+?]|\{\d+(?:,\d*)?\})\s*(?:[*+?]|\{)", pattern):
181
+ raise ConfigurationError("Nested or adjacent regex quantifiers are not allowed.")
182
+ quantifier_text = re.sub(r"\[[^\]]*\]", "X", pattern)
183
+ if "{," in quantifier_text:
184
+ raise ConfigurationError("Regex repetition bounds must include an explicit lower bound.")
185
+ unbounded = re.findall(r"[*+]|\{\d+,\}", quantifier_text)
186
+ if len(unbounded) > 1:
187
+ raise ConfigurationError(
188
+ "The safe DNA-regex subset allows at most one unbounded quantifier."
189
+ )
190
+ quantifiers = re.findall(r"[*+?]|\{\d+(?:,\d*)?\}", quantifier_text)
191
+ quantifier_count = len(quantifiers)
192
+ if quantifier_count > 32:
193
+ raise ConfigurationError("Regex motif contains too many quantifiers for bounded scanning.")
194
+ multiplier = 1
195
+ for quantifier in quantifiers:
196
+ if quantifier in ("*", "+") or quantifier.endswith(",}"):
197
+ factor = max(1, scan_length)
198
+ elif quantifier == "?":
199
+ factor = 2
200
+ else:
201
+ bounds = quantifier[1:-1].split(",")
202
+ lower = int(bounds[0])
203
+ upper = lower if len(bounds) == 1 else int(bounds[1])
204
+ if upper < lower:
205
+ raise ConfigurationError(
206
+ "Regex repetition upper bound cannot be below its lower bound."
207
+ )
208
+ factor = max(1, upper, upper - lower + 1)
209
+ multiplier *= factor
210
+ if multiplier > 50_000_000:
211
+ raise ConfigurationError("Regex quantifier expansion exceeds the safety limit.")
212
+ return multiplier
213
+
214
+
215
+ def _mapped_location(
216
+ *,
217
+ local_start: int,
218
+ local_end: int,
219
+ segment_text_length: int,
220
+ segment: object,
221
+ strand: Strand,
222
+ circular: bool,
223
+ ) -> tuple[Location, Location, bool]:
224
+ from dnakit.patterns._shared import Segment
225
+
226
+ if not isinstance(segment, Segment):
227
+ raise AssertionError("Internal segment type mismatch.")
228
+ length = local_end - local_start
229
+ original_start = local_start if strand is Strand.FORWARD else segment_text_length - local_end
230
+ if circular:
231
+ return circular_location(original_start, length, segment_text_length)
232
+ symbol_location, coordinate_location = segment_location(
233
+ segment, original_start, original_start + length
234
+ )
235
+ return symbol_location, coordinate_location, False
236
+
237
+
238
+ def scan_motif(
239
+ value: SequenceInput,
240
+ motif: str,
241
+ *,
242
+ mode: MotifMode = "exact",
243
+ name: str = "motif",
244
+ strand: Strand | str = Strand.BOTH,
245
+ overlapping: bool = True,
246
+ merge_strands: bool = False,
247
+ max_matches: int = 100_000,
248
+ max_scan_length: int = 1_000_000,
249
+ max_pattern_length: int = 1_000,
250
+ max_scan_cells: int = 50_000_000,
251
+ ) -> PatternResult[MotifHit]:
252
+ """Scan literal, IUPAC-compatible, or Python-regex motifs on selected strands.
253
+
254
+ Regex scanning is bounded by ``max_scan_length`` and ``max_pattern_length``;
255
+ circular origin wrapping is supported only for fixed-length exact/IUPAC motifs.
256
+ """
257
+
258
+ sequence, sequence_id = resolve_sequence(value)
259
+ validate_positive_int(max_matches, "max_matches")
260
+ validate_positive_int(max_scan_length, "max_scan_length")
261
+ validate_positive_int(max_pattern_length, "max_pattern_length")
262
+ validate_positive_int(max_scan_cells, "max_scan_cells")
263
+ validate_bool(overlapping, "overlapping")
264
+ validate_bool(merge_strands, "merge_strands")
265
+ if sequence.symbol_length > max_scan_length:
266
+ raise ConfigurationError(
267
+ "Sequence exceeds max_scan_length.",
268
+ context={"symbol_length": sequence.symbol_length, "max_scan_length": max_scan_length},
269
+ )
270
+ if not isinstance(name, str) or not name.strip():
271
+ raise ConfigurationError("Motif name must be non-empty.")
272
+ raw_mode: object = mode
273
+ if not isinstance(raw_mode, str) or raw_mode not in ("exact", "iupac", "regex"):
274
+ raise ConfigurationError("Motif mode must be exact, iupac, or regex.")
275
+ resolved_mode = cast(MotifMode, raw_mode)
276
+ if not isinstance(motif, str) or not motif:
277
+ raise ConfigurationError("motif must be a non-empty string.")
278
+ if len(motif) > max_pattern_length:
279
+ raise ConfigurationError("Motif exceeds max_pattern_length.")
280
+ resolved_motif = motif if resolved_mode == "regex" else validate_iupac_text(motif, "motif")
281
+ regex_work_multiplier = (
282
+ _validate_safe_regex(resolved_motif, sequence.symbol_length)
283
+ if resolved_mode == "regex"
284
+ else 1
285
+ )
286
+ circular = sequence.topology.value == "circular"
287
+ require_ungapped_circular(sequence, "motif scanning")
288
+ if circular and resolved_mode == "regex":
289
+ raise SequenceError(
290
+ "Regex motifs do not have a fixed circular wrap length.",
291
+ code="CIRCULAR_REGEX_UNSUPPORTED",
292
+ hint="Use exact/IUPAC mode or linearize the sequence at a documented origin.",
293
+ )
294
+ selected_strands = coerce_strands(strand)
295
+ estimated_cells = (
296
+ sequence.symbol_length * len(resolved_motif) * len(selected_strands) * regex_work_multiplier
297
+ )
298
+ if estimated_cells > max_scan_cells:
299
+ raise ConfigurationError(
300
+ "Motif scan exceeds max_scan_cells.",
301
+ context={"estimated_cells": estimated_cells, "max_scan_cells": max_scan_cells},
302
+ )
303
+
304
+ hits: list[MotifHit] = []
305
+ truncated = False
306
+ seen: dict[tuple[object, object, str], int] = {}
307
+ for item in segments(sequence):
308
+ for resolved_strand in selected_strands:
309
+ oriented = (
310
+ item.text
311
+ if resolved_strand is Strand.FORWARD
312
+ else reverse_complement_text(item.text)
313
+ )
314
+ iterator: Iterator[tuple[int, int, str]]
315
+ if resolved_mode == "regex":
316
+ iterator = _regex_matches(oriented, resolved_motif, overlapping=overlapping)
317
+ else:
318
+ iterator = _fixed_matches(
319
+ oriented,
320
+ resolved_motif,
321
+ mode=resolved_mode,
322
+ overlapping=overlapping,
323
+ circular=circular,
324
+ )
325
+ for start, end, matched in iterator:
326
+ symbol_location, coordinate_location, wrapped = _mapped_location(
327
+ local_start=start,
328
+ local_end=end,
329
+ segment_text_length=len(item.text),
330
+ segment=item,
331
+ strand=resolved_strand,
332
+ circular=circular,
333
+ )
334
+ key = (symbol_location, coordinate_location, matched)
335
+ if merge_strands and key in seen:
336
+ old_index = seen[key]
337
+ previous = hits[old_index]
338
+ hits[old_index] = MotifHit(
339
+ motif_name=previous.motif_name,
340
+ matched_sequence=previous.matched_sequence,
341
+ strand=Strand.BOTH,
342
+ symbol_location=previous.symbol_location,
343
+ coordinate_location=previous.coordinate_location,
344
+ score=None,
345
+ threshold=None,
346
+ wraps_origin=previous.wraps_origin,
347
+ )
348
+ continue
349
+ if len(hits) >= max_matches:
350
+ truncated = True
351
+ break
352
+ seen[key] = len(hits)
353
+ hits.append(
354
+ MotifHit(
355
+ motif_name=name,
356
+ matched_sequence=matched,
357
+ strand=resolved_strand,
358
+ symbol_location=symbol_location,
359
+ coordinate_location=coordinate_location,
360
+ score=None,
361
+ threshold=None,
362
+ wraps_origin=wrapped,
363
+ )
364
+ )
365
+ if truncated:
366
+ break
367
+ if truncated:
368
+ break
369
+ result = build_result(
370
+ sequence,
371
+ sequence_id,
372
+ name="motif_scan",
373
+ method=resolved_mode,
374
+ algorithm_version="1.0",
375
+ parameters={
376
+ "motif": resolved_motif,
377
+ "motif_name": name,
378
+ "strand": str(strand),
379
+ "overlapping": overlapping,
380
+ "merge_strands": merge_strands,
381
+ "max_scan_length": max_scan_length,
382
+ "max_pattern_length": max_pattern_length,
383
+ "estimated_scan_cells": estimated_cells,
384
+ "max_scan_cells": max_scan_cells,
385
+ "target_iupac_rule": "set-intersection" if resolved_mode == "iupac" else "literal",
386
+ "regex_safety": "restricted-dna-subset" if resolved_mode == "regex" else None,
387
+ "regex_work_multiplier": regex_work_multiplier if resolved_mode == "regex" else None,
388
+ },
389
+ hits=hits,
390
+ max_matches=max_matches,
391
+ truncated=truncated,
392
+ provenance=pattern_provenance(reimplementation=resolved_mode == "regex"),
393
+ )
394
+ return cast(PatternResult[MotifHit], result)
395
+
396
+
397
+ def _background(values: Mapping[str, float] | None) -> dict[str, float]:
398
+ if values is None:
399
+ return {base: 0.25 for base in "ACGT"}
400
+ if set(values) != set("ACGT"):
401
+ raise ConfigurationError("PWM background must contain exactly A, C, G, and T.")
402
+ resolved: dict[str, float] = {}
403
+ for base in "ACGT":
404
+ value = values[base]
405
+ if (
406
+ isinstance(value, bool)
407
+ or not isinstance(value, (int, float))
408
+ or not math.isfinite(value)
409
+ or value <= 0
410
+ ):
411
+ raise ConfigurationError("PWM background probabilities must be finite and positive.")
412
+ resolved[base] = float(value)
413
+ total = math.fsum(resolved.values())
414
+ return {base: resolved[base] / total for base in "ACGT"}
415
+
416
+
417
+ def _score_pwm(
418
+ text: str,
419
+ pwm: PWM,
420
+ background: Mapping[str, float],
421
+ pseudocount: float,
422
+ ) -> float | None:
423
+ if any(base not in "ACGT" for base in text):
424
+ return None
425
+ scores: list[float] = []
426
+ for index, base in enumerate(text):
427
+ column_total = math.fsum(pwm.row(item)[index] + pseudocount for item in "ACGT")
428
+ probability = (pwm.row(base)[index] + pseudocount) / column_total
429
+ if probability <= 0:
430
+ return None
431
+ scores.append(math.log2(probability / background[base]))
432
+ return math.fsum(scores)
433
+
434
+
435
+ def scan_pwm(
436
+ value: SequenceInput,
437
+ pwm: PWM,
438
+ *,
439
+ threshold: float,
440
+ background: Mapping[str, float] | None = None,
441
+ pseudocount: float = 0.0,
442
+ strand: Strand | str = Strand.BOTH,
443
+ max_matches: int = 100_000,
444
+ max_scan_length: int = 1_000_000,
445
+ max_pwm_length: int = 10_000,
446
+ max_score_cells: int = 50_000_000,
447
+ ) -> PatternResult[MotifHit]:
448
+ """Scan a user-provided PWM with explicit log2-odds threshold and background."""
449
+
450
+ sequence, sequence_id = resolve_sequence(value)
451
+ if not isinstance(pwm, PWM):
452
+ raise ConfigurationError("pwm must be a PWM object.")
453
+ validate_finite_score(threshold, "threshold")
454
+ validate_finite_score(pseudocount, "pseudocount")
455
+ if threshold is None:
456
+ raise ConfigurationError("threshold must be a finite number.")
457
+ if pseudocount is None:
458
+ raise ConfigurationError("pseudocount must be a finite non-negative number.")
459
+ if pseudocount < 0:
460
+ raise ConfigurationError("pseudocount must be non-negative.")
461
+ validate_positive_int(max_matches, "max_matches")
462
+ validate_positive_int(max_scan_length, "max_scan_length")
463
+ validate_positive_int(max_pwm_length, "max_pwm_length")
464
+ validate_positive_int(max_score_cells, "max_score_cells")
465
+ if sequence.symbol_length > max_scan_length:
466
+ raise ConfigurationError("Sequence exceeds max_scan_length.")
467
+ if pwm.length > max_pwm_length:
468
+ raise ConfigurationError("PWM exceeds max_pwm_length.")
469
+ resolved_background = _background(background)
470
+ circular = sequence.topology.value == "circular"
471
+ require_ungapped_circular(sequence, "PWM scanning")
472
+ if circular and pwm.length > sequence.symbol_length:
473
+ raise ConfigurationError("A circular PWM cannot be longer than the sequence.")
474
+ selected_strands = coerce_strands(strand)
475
+ possible_windows = sum(
476
+ len(item.text) if circular else max(0, len(item.text) - pwm.length + 1)
477
+ for item in segments(sequence)
478
+ ) * len(selected_strands)
479
+ if possible_windows * pwm.length > max_score_cells:
480
+ raise ConfigurationError(
481
+ "PWM scan exceeds max_score_cells.",
482
+ context={
483
+ "possible_windows": possible_windows,
484
+ "pwm_length": pwm.length,
485
+ "max_score_cells": max_score_cells,
486
+ },
487
+ )
488
+
489
+ hits: list[MotifHit] = []
490
+ truncated = False
491
+ for item in segments(sequence):
492
+ for resolved_strand in selected_strands:
493
+ oriented = (
494
+ item.text
495
+ if resolved_strand is Strand.FORWARD
496
+ else reverse_complement_text(item.text)
497
+ )
498
+ searchable = (
499
+ oriented + oriented[: pwm.length - 1] if circular and pwm.length > 1 else oriented
500
+ )
501
+ limit = len(oriented) if circular else len(oriented) - pwm.length + 1
502
+ for start in range(max(0, limit)):
503
+ candidate = searchable[start : start + pwm.length]
504
+ score = _score_pwm(candidate, pwm, resolved_background, float(pseudocount))
505
+ if score is None or score < threshold:
506
+ continue
507
+ if len(hits) >= max_matches:
508
+ truncated = True
509
+ break
510
+ symbol_location, coordinate_location, wrapped = _mapped_location(
511
+ local_start=start,
512
+ local_end=start + pwm.length,
513
+ segment_text_length=len(item.text),
514
+ segment=item,
515
+ strand=resolved_strand,
516
+ circular=circular,
517
+ )
518
+ hits.append(
519
+ MotifHit(
520
+ motif_name=pwm.name,
521
+ matched_sequence=candidate,
522
+ strand=resolved_strand,
523
+ symbol_location=symbol_location,
524
+ coordinate_location=coordinate_location,
525
+ score=score,
526
+ threshold=float(threshold),
527
+ wraps_origin=wrapped,
528
+ )
529
+ )
530
+ if truncated:
531
+ break
532
+ if truncated:
533
+ break
534
+ result = build_result(
535
+ sequence,
536
+ sequence_id,
537
+ name="pwm_scan",
538
+ method="log2_odds",
539
+ algorithm_version="1.0",
540
+ parameters={
541
+ "pwm_name": pwm.name,
542
+ "pwm_length": pwm.length,
543
+ "threshold": float(threshold),
544
+ "background": resolved_background,
545
+ "pseudocount": float(pseudocount),
546
+ "strand": str(strand),
547
+ "ambiguous_target_policy": "skip-window",
548
+ "max_scan_length": max_scan_length,
549
+ "max_pwm_length": max_pwm_length,
550
+ "score_cells": possible_windows * pwm.length,
551
+ "max_score_cells": max_score_cells,
552
+ },
553
+ hits=hits,
554
+ max_matches=max_matches,
555
+ truncated=truncated,
556
+ provenance=pattern_provenance(
557
+ reimplementation=True,
558
+ reference_name="FIMO-style PWM log-odds scoring boundary",
559
+ reference_version="DNAKit-1.0",
560
+ ),
561
+ )
562
+ return cast(PatternResult[MotifHit], result)
563
+
564
+
565
+ def scan_promoter_motifs(
566
+ value: SequenceInput,
567
+ *,
568
+ motifs: Mapping[str, str] | None = None,
569
+ strand: Strand | str = Strand.BOTH,
570
+ max_matches: int = 100_000,
571
+ max_scan_length: int = 1_000_000,
572
+ max_scan_cells: int = 50_000_000,
573
+ max_motifs: int = 10_000,
574
+ ) -> PatternResult[MotifHit]:
575
+ """Scan predefined/user consensus motifs without predicting promoter activity."""
576
+
577
+ sequence, sequence_id = resolve_sequence(value)
578
+ if motifs is not None and not isinstance(motifs, Mapping):
579
+ raise ConfigurationError("motifs must be a mapping from names to IUPAC definitions.")
580
+ definitions = dict(PROMOTER_MOTIFS if motifs is None else motifs)
581
+ if not definitions:
582
+ raise ConfigurationError("At least one promoter motif definition is required.")
583
+ if any(not isinstance(motif_name, str) or not motif_name.strip() for motif_name in definitions):
584
+ raise ConfigurationError("Promoter motif names must be non-empty strings.")
585
+ validate_positive_int(max_matches, "max_matches")
586
+ validate_positive_int(max_scan_length, "max_scan_length")
587
+ validate_positive_int(max_scan_cells, "max_scan_cells")
588
+ validate_positive_int(max_motifs, "max_motifs")
589
+ if len(definitions) > max_motifs:
590
+ raise ConfigurationError("Promoter motif panel exceeds max_motifs.")
591
+ definitions = {
592
+ motif_name: validate_iupac_text(motif, f"motifs[{motif_name!r}]")
593
+ for motif_name, motif in definitions.items()
594
+ }
595
+ selected_strands = coerce_strands(strand)
596
+ estimated_total_cells = (
597
+ sequence.symbol_length
598
+ * len(selected_strands)
599
+ * sum(len(motif) for motif in definitions.values())
600
+ )
601
+ if estimated_total_cells > max_scan_cells:
602
+ raise ConfigurationError(
603
+ "Promoter motif panel exceeds max_scan_cells.",
604
+ context={
605
+ "estimated_total_cells": estimated_total_cells,
606
+ "max_scan_cells": max_scan_cells,
607
+ },
608
+ )
609
+ hits: list[MotifHit] = []
610
+ truncated = False
611
+ for motif_name in sorted(definitions):
612
+ remaining = max_matches - len(hits)
613
+ if remaining <= 0:
614
+ truncated = True
615
+ break
616
+ partial = scan_motif(
617
+ sequence,
618
+ definitions[motif_name],
619
+ mode="iupac",
620
+ name=motif_name,
621
+ strand=strand,
622
+ max_matches=remaining,
623
+ max_scan_length=max_scan_length,
624
+ max_scan_cells=max_scan_cells,
625
+ )
626
+ hits.extend(partial.hits)
627
+ truncated = truncated or partial.truncated
628
+ result = build_result(
629
+ sequence,
630
+ sequence_id,
631
+ name="promoter_motif_scan",
632
+ method="iupac_consensus_candidates",
633
+ algorithm_version="1.0",
634
+ parameters={
635
+ "motifs": definitions,
636
+ "catalog": "DNAKit built-in boundary catalog" if motifs is None else "user-provided",
637
+ "catalog_version": PROMOTER_CATALOG_VERSION if motifs is None else None,
638
+ "activity_prediction": False,
639
+ "strand": str(strand),
640
+ "max_scan_length": max_scan_length,
641
+ "estimated_total_scan_cells": estimated_total_cells,
642
+ "max_scan_cells": max_scan_cells,
643
+ "max_motifs": max_motifs,
644
+ },
645
+ hits=hits,
646
+ max_matches=max_matches,
647
+ truncated=truncated,
648
+ provenance=pattern_provenance(
649
+ reimplementation=True,
650
+ reference_name="consensus promoter motif definitions",
651
+ reference_version=PROMOTER_CATALOG_VERSION if motifs is None else "user-provided",
652
+ ),
653
+ )
654
+ return cast(PatternResult[MotifHit], result)
655
+
656
+
657
+ def scan_tf_pwm(
658
+ value: SequenceInput,
659
+ tf_name: str,
660
+ pwm: PWM,
661
+ *,
662
+ threshold: float,
663
+ background: Mapping[str, float] | None = None,
664
+ pseudocount: float = 0.0,
665
+ strand: Strand | str = Strand.BOTH,
666
+ max_matches: int = 100_000,
667
+ max_scan_length: int = 1_000_000,
668
+ max_pwm_length: int = 10_000,
669
+ max_score_cells: int = 50_000_000,
670
+ ) -> PatternResult[MotifHit]:
671
+ """Scan a user-supplied TF PWM; no binding-strength or database claim is made."""
672
+
673
+ if not isinstance(tf_name, str) or not tf_name.strip():
674
+ raise ConfigurationError("tf_name must be non-empty.")
675
+ if not isinstance(pwm, PWM):
676
+ raise ConfigurationError("pwm must be a PWM object.")
677
+ renamed = PWM(tf_name, {base: pwm.row(base) for base in "ACGT"})
678
+ result = scan_pwm(
679
+ value,
680
+ renamed,
681
+ threshold=threshold,
682
+ background=background,
683
+ pseudocount=pseudocount,
684
+ strand=strand,
685
+ max_matches=max_matches,
686
+ max_scan_length=max_scan_length,
687
+ max_pwm_length=max_pwm_length,
688
+ max_score_cells=max_score_cells,
689
+ )
690
+ parameters = dict(result.parameters)
691
+ parameters.update({"tf_name": tf_name, "binding_strength_prediction": False})
692
+ return PatternResult(
693
+ name="tf_motif_scan",
694
+ method=result.method,
695
+ algorithm_version=result.algorithm_version,
696
+ sequence_id=result.sequence_id,
697
+ parameters=type(result.parameters)(parameters),
698
+ hits=result.hits,
699
+ inspected_symbol_count=result.inspected_symbol_count,
700
+ gap_count=result.gap_count,
701
+ unknown_gap_count=result.unknown_gap_count,
702
+ max_matches=result.max_matches,
703
+ truncated=result.truncated,
704
+ coordinate_system=result.coordinate_system,
705
+ gap_policy=result.gap_policy,
706
+ topology=result.topology,
707
+ provenance=result.provenance,
708
+ issues=result.issues,
709
+ )
710
+
711
+
712
+ __all__ = [
713
+ "MAX_PWM_LENGTH",
714
+ "PROMOTER_CATALOG_VERSION",
715
+ "PROMOTER_MOTIFS",
716
+ "PWM",
717
+ "scan_motif",
718
+ "scan_promoter_motifs",
719
+ "scan_pwm",
720
+ "scan_tf_pwm",
721
+ ]