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,193 @@
1
+ """Native length, composition, skew, and CpG descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from typing import cast
7
+
8
+ from dnakit.core._json import FrozenDict
9
+ from dnakit.core.gap import Gap
10
+ from dnakit.descriptors._shared import (
11
+ DescriptorAmbiguityPolicy,
12
+ SequenceInput,
13
+ canonical_runs,
14
+ coerce_ambiguity_policy,
15
+ fragments,
16
+ reject_ambiguity,
17
+ sequence_and_id,
18
+ )
19
+ from dnakit.descriptors.results import (
20
+ CompositionResult,
21
+ ContentResult,
22
+ CpGResult,
23
+ LengthResult,
24
+ SkewResult,
25
+ )
26
+
27
+
28
+ def length_features(value: SequenceInput) -> LengthResult:
29
+ """Return lengths without guessing the span of an unknown-length gap."""
30
+
31
+ sequence, sequence_id = sequence_and_id(value)
32
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
33
+ return LengthResult(
34
+ name="length",
35
+ method="explicit_sequence_parts",
36
+ sequence_id=sequence_id,
37
+ ambiguity_policy=None,
38
+ cross_gaps=False,
39
+ gap_count=len(gaps),
40
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
41
+ symbol_length=sequence.symbol_length,
42
+ coordinate_span=sequence.coordinate_span,
43
+ canonical_base_count=sequence.canonical_base_count,
44
+ ambiguity_length=sequence.ambiguity_count,
45
+ known_gap_length=sum(gap.length or 0 for gap in gaps),
46
+ )
47
+
48
+
49
+ def base_composition(
50
+ value: SequenceInput,
51
+ *,
52
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
53
+ ) -> CompositionResult:
54
+ """Count A/C/G/T; ignored ambiguity is excluded from the fraction denominator."""
55
+
56
+ sequence, sequence_id = sequence_and_id(value)
57
+ policy = coerce_ambiguity_policy(ambiguity_policy)
58
+ reject_ambiguity(sequence, policy)
59
+ counts = Counter(symbol for symbol in sequence.symbols if symbol in "ACGT")
60
+ denominator = sum(counts.values())
61
+ ordered_counts = {base: counts[base] for base in "ACGT"}
62
+ fractions = {
63
+ base: (ordered_counts[base] / denominator if denominator else 0.0) for base in "ACGT"
64
+ }
65
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
66
+ return CompositionResult(
67
+ name="base_composition",
68
+ method="canonical_base_count",
69
+ sequence_id=sequence_id,
70
+ ambiguity_policy=policy,
71
+ cross_gaps=False,
72
+ gap_count=len(gaps),
73
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
74
+ counts=FrozenDict(ordered_counts),
75
+ fractions=FrozenDict(fractions),
76
+ denominator=denominator,
77
+ ignored_ambiguity_count=(
78
+ sequence.ambiguity_count if policy is DescriptorAmbiguityPolicy.IGNORE else 0
79
+ ),
80
+ )
81
+
82
+
83
+ def gc_at_content(
84
+ value: SequenceInput,
85
+ *,
86
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
87
+ ) -> ContentResult:
88
+ """Calculate GC and AT fractions over the A/C/G/T denominator."""
89
+
90
+ composition = base_composition(value, ambiguity_policy=ambiguity_policy)
91
+ gc_count = cast(int, composition.counts["G"]) + cast(int, composition.counts["C"])
92
+ at_count = cast(int, composition.counts["A"]) + cast(int, composition.counts["T"])
93
+ denominator = composition.denominator
94
+ return ContentResult(
95
+ name="gc_at_content",
96
+ method="canonical_base_fraction",
97
+ sequence_id=composition.sequence_id,
98
+ ambiguity_policy=composition.ambiguity_policy,
99
+ cross_gaps=False,
100
+ gap_count=composition.gap_count,
101
+ unknown_gap_count=composition.unknown_gap_count,
102
+ gc_count=gc_count,
103
+ at_count=at_count,
104
+ gc_fraction=gc_count / denominator if denominator else None,
105
+ at_fraction=at_count / denominator if denominator else None,
106
+ denominator=denominator,
107
+ ignored_ambiguity_count=composition.ignored_ambiguity_count,
108
+ )
109
+
110
+
111
+ def base_skew(
112
+ value: SequenceInput,
113
+ *,
114
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
115
+ ) -> SkewResult:
116
+ """Calculate GC and AT skew; a zero formula denominator produces None."""
117
+
118
+ composition = base_composition(value, ambiguity_policy=ambiguity_policy)
119
+ a = cast(int, composition.counts["A"])
120
+ c = cast(int, composition.counts["C"])
121
+ g = cast(int, composition.counts["G"])
122
+ t = cast(int, composition.counts["T"])
123
+ gc_denominator = g + c
124
+ at_denominator = a + t
125
+ return SkewResult(
126
+ name="base_skew",
127
+ method="gc=(g-c)/(g+c);at=(a-t)/(a+t)",
128
+ sequence_id=composition.sequence_id,
129
+ ambiguity_policy=composition.ambiguity_policy,
130
+ cross_gaps=False,
131
+ gap_count=composition.gap_count,
132
+ unknown_gap_count=composition.unknown_gap_count,
133
+ gc_skew=(g - c) / gc_denominator if gc_denominator else None,
134
+ at_skew=(a - t) / at_denominator if at_denominator else None,
135
+ gc_denominator=gc_denominator,
136
+ at_denominator=at_denominator,
137
+ ignored_ambiguity_count=composition.ignored_ambiguity_count,
138
+ )
139
+
140
+
141
+ def cpg_features(
142
+ value: SequenceInput,
143
+ *,
144
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
145
+ cross_gaps: bool = False,
146
+ ) -> CpGResult:
147
+ """Calculate CpG statistics without crossing gaps or ambiguity by default.
148
+
149
+ Density is ``count(CG) / eligible adjacent canonical pairs``. Observed/expected
150
+ is ``count(CG) * canonical_base_count / (count(C) * count(G))``. Undefined
151
+ ratios are returned as ``None`` rather than NaN or infinity.
152
+ """
153
+
154
+ sequence, sequence_id = sequence_and_id(value)
155
+ policy = coerce_ambiguity_policy(ambiguity_policy)
156
+ reject_ambiguity(sequence, policy)
157
+ text_runs = tuple(
158
+ run
159
+ for fragment in fragments(sequence, cross_gaps=cross_gaps)
160
+ for run in canonical_runs(fragment)
161
+ )
162
+ cpg_count = sum(run.count("CG") for run in text_runs)
163
+ pair_denominator = sum(max(0, len(run) - 1) for run in text_runs)
164
+ canonical_length = sum(len(run) for run in text_runs)
165
+ c_count = sum(run.count("C") for run in text_runs)
166
+ g_count = sum(run.count("G") for run in text_runs)
167
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
168
+ return CpGResult(
169
+ name="cpg",
170
+ method="adjacent_cg_and_length_normalized_oe",
171
+ sequence_id=sequence_id,
172
+ ambiguity_policy=policy,
173
+ cross_gaps=cross_gaps,
174
+ gap_count=len(gaps),
175
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
176
+ cpg_count=cpg_count,
177
+ density=cpg_count / pair_denominator if pair_denominator else None,
178
+ observed_expected=(
179
+ cpg_count * canonical_length / (c_count * g_count) if c_count and g_count else None
180
+ ),
181
+ adjacent_pair_denominator=pair_denominator,
182
+ expected_length_denominator=canonical_length,
183
+ c_count=c_count,
184
+ g_count=g_count,
185
+ ignored_ambiguity_count=(
186
+ sequence.ambiguity_count if policy is DescriptorAmbiguityPolicy.IGNORE else 0
187
+ ),
188
+ density_formula="count(CG)/eligible_adjacent_canonical_pairs",
189
+ observed_expected_formula="count(CG)*canonical_base_count/(count(C)*count(G))",
190
+ )
191
+
192
+
193
+ __all__ = ["base_composition", "base_skew", "cpg_features", "gc_at_content", "length_features"]
@@ -0,0 +1,195 @@
1
+ """Native standard-code codon statistics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass
7
+
8
+ from dnakit.core._json import FrozenDict
9
+ from dnakit.core.gap import Gap
10
+ from dnakit.core.sequence import DNASequence
11
+ from dnakit.descriptors._shared import (
12
+ DescriptorAmbiguityPolicy,
13
+ SequenceInput,
14
+ coerce_ambiguity_policy,
15
+ reject_ambiguity,
16
+ sequence_and_id,
17
+ symbol_view,
18
+ validate_bool,
19
+ )
20
+ from dnakit.descriptors.results import CodonResult
21
+ from dnakit.exceptions import ConfigurationError
22
+
23
+ _STANDARD_STARTS = frozenset({"ATG"})
24
+ _STANDARD_STOPS = frozenset({"TAA", "TAG", "TGA"})
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _CodonAudit:
29
+ counts: Counter[str]
30
+ ignored_ambiguity: int
31
+ gap_interrupted: int
32
+ incomplete_bases: int
33
+ phase_unresolved: bool
34
+ unresolved_downstream_bases: int
35
+
36
+
37
+ def _coordinate_codon_audit(sequence: DNASequence, frame: int) -> _CodonAudit:
38
+ slots: dict[int, dict[int, str]] = {}
39
+ known_gaps: list[tuple[int, int]] = []
40
+ coordinate: int | None = 0
41
+ unknown_gap_start: int | None = None
42
+ unresolved_downstream = 0
43
+ for part in sequence.parts:
44
+ if isinstance(part, str):
45
+ if coordinate is None:
46
+ unresolved_downstream += len(part)
47
+ continue
48
+ for symbol in part:
49
+ if coordinate >= frame:
50
+ relative = coordinate - frame
51
+ slots.setdefault(relative // 3, {})[relative % 3] = symbol
52
+ coordinate += 1
53
+ elif coordinate is not None:
54
+ if part.length is None:
55
+ unknown_gap_start = coordinate
56
+ coordinate = None
57
+ else:
58
+ known_gaps.append((coordinate, coordinate + part.length))
59
+ coordinate += part.length
60
+
61
+ counts: Counter[str] = Counter()
62
+ ignored_ambiguity = 0
63
+ interrupted = 0
64
+ incomplete = 0
65
+ for slot, symbols in slots.items():
66
+ codon_start = frame + slot * 3
67
+ codon_end = codon_start + 3
68
+ if len(symbols) == 3:
69
+ codon = "".join(symbols[position] for position in range(3))
70
+ if any(base not in "ACGT" for base in codon):
71
+ ignored_ambiguity += 1
72
+ else:
73
+ counts[codon] += 1
74
+ continue
75
+ overlaps_known_gap = any(
76
+ gap_start < codon_end and gap_end > codon_start for gap_start, gap_end in known_gaps
77
+ )
78
+ overlaps_unknown_gap = (
79
+ unknown_gap_start is not None and codon_start <= unknown_gap_start < codon_end
80
+ )
81
+ if overlaps_known_gap or overlaps_unknown_gap:
82
+ interrupted += 1
83
+ else:
84
+ incomplete += len(symbols)
85
+ return _CodonAudit(
86
+ counts,
87
+ ignored_ambiguity,
88
+ interrupted,
89
+ incomplete,
90
+ unknown_gap_start is not None,
91
+ unresolved_downstream,
92
+ )
93
+
94
+
95
+ def _concatenated_codon_audit(sequence: DNASequence, frame: int) -> _CodonAudit:
96
+ view = symbol_view(sequence)
97
+ codon_segments = view.crossable_segment_ids
98
+ counts: Counter[str] = Counter()
99
+ ignored_ambiguity = 0
100
+ gap_interrupted = 0
101
+ complete_candidate_count = 0
102
+ for start in range(frame, len(view.text) - 2, 3):
103
+ complete_candidate_count += 1
104
+ segments = codon_segments[start : start + 3]
105
+ if len(set(segments)) != 1:
106
+ gap_interrupted += 1
107
+ continue
108
+ codon = view.text[start : start + 3]
109
+ if any(base not in "ACGT" for base in codon):
110
+ ignored_ambiguity += 1
111
+ else:
112
+ counts[codon] += 1
113
+ eligible_after_frame = max(0, len(view.text) - frame)
114
+ return _CodonAudit(
115
+ counts,
116
+ ignored_ambiguity,
117
+ gap_interrupted,
118
+ eligible_after_frame - complete_candidate_count * 3,
119
+ False,
120
+ 0,
121
+ )
122
+
123
+
124
+ def codon_statistics(
125
+ value: SequenceInput,
126
+ *,
127
+ frame: int = 0,
128
+ genetic_code: int = 1,
129
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
130
+ cross_gaps: bool = False,
131
+ ) -> CodonResult:
132
+ """Count in-frame codons using NCBI standard genetic code table 1.
133
+
134
+ By default, ``frame`` is a zero-based offset in the full sequence coordinate
135
+ span: known Gap lengths advance phase, codons overlapping a Gap are omitted,
136
+ and an unknown-length Gap makes downstream phase unresolved. With
137
+ ``cross_gaps=True``, crossable Gaps are explicitly omitted and phase follows
138
+ concatenated symbol coordinates; a ``crossable=False`` Gap remains a hard
139
+ boundary. An ignored ambiguous codon is omitted without changing phase.
140
+ """
141
+
142
+ sequence, sequence_id = sequence_and_id(value)
143
+ policy = coerce_ambiguity_policy(ambiguity_policy)
144
+ reject_ambiguity(sequence, policy)
145
+ if isinstance(frame, bool) or not isinstance(frame, int) or frame not in {0, 1, 2}:
146
+ raise ConfigurationError(
147
+ "frame must be the zero-based offset 0, 1, or 2.",
148
+ context={"frame": frame},
149
+ )
150
+ if genetic_code != 1 or isinstance(genetic_code, bool):
151
+ raise ConfigurationError(
152
+ "MVP codon statistics currently support only genetic_code=1.",
153
+ context={"genetic_code": genetic_code},
154
+ )
155
+ validate_bool(cross_gaps, "cross_gaps")
156
+
157
+ audit = (
158
+ _concatenated_codon_audit(sequence, frame)
159
+ if cross_gaps
160
+ else _coordinate_codon_audit(sequence, frame)
161
+ )
162
+
163
+ ordered_counts = dict(sorted(audit.counts.items()))
164
+ codon_count = sum(ordered_counts.values())
165
+ frequencies = {codon: count / codon_count for codon, count in ordered_counts.items()}
166
+ start_count = sum(audit.counts[codon] for codon in _STANDARD_STARTS)
167
+ stop_count = sum(audit.counts[codon] for codon in _STANDARD_STOPS)
168
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
169
+ return CodonResult(
170
+ name="codon",
171
+ method="in_frame_standard_genetic_code",
172
+ sequence_id=sequence_id,
173
+ ambiguity_policy=policy,
174
+ cross_gaps=cross_gaps,
175
+ gap_count=len(gaps),
176
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
177
+ frame=frame,
178
+ genetic_code=genetic_code,
179
+ counts=FrozenDict(ordered_counts),
180
+ frequencies=FrozenDict(frequencies),
181
+ codon_count=codon_count,
182
+ start_count=start_count,
183
+ stop_count=stop_count,
184
+ start_density=start_count / codon_count if codon_count else None,
185
+ stop_density=stop_count / codon_count if codon_count else None,
186
+ incomplete_base_count=audit.incomplete_bases,
187
+ ignored_ambiguity_codon_count=audit.ignored_ambiguity,
188
+ gap_interrupted_codon_count=audit.gap_interrupted,
189
+ phase_coordinate_system=("concatenated-symbol" if cross_gaps else "sequence-coordinate"),
190
+ phase_unresolved_after_gap=audit.phase_unresolved,
191
+ unresolved_downstream_base_count=audit.unresolved_downstream_bases,
192
+ )
193
+
194
+
195
+ __all__ = ["codon_statistics"]
@@ -0,0 +1,201 @@
1
+ """Bounded, explicit sequence-complexity and exact-repeat descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+
8
+ from dnakit.core import Gap, Topology
9
+ from dnakit.core._json import FrozenDict
10
+ from dnakit.exceptions import ConfigurationError
11
+
12
+ from ._shared import (
13
+ DescriptorAmbiguityPolicy,
14
+ SequenceInput,
15
+ canonical_runs,
16
+ coerce_ambiguity_policy,
17
+ fragments,
18
+ reject_ambiguity,
19
+ sequence_and_id,
20
+ validate_bool,
21
+ validate_positive_int,
22
+ )
23
+ from .results import ComplexityResult, ExactRepeatResult, RepeatRun
24
+
25
+
26
+ def linguistic_complexity(
27
+ value: SequenceInput,
28
+ *,
29
+ max_word_size: int = 6,
30
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
31
+ cross_gaps: bool = False,
32
+ max_observations: int = 10_000_000,
33
+ ) -> ComplexityResult:
34
+ """Compute observed/possible vocabulary ratios for k=1..max_word_size.
35
+
36
+ For each k, the denominator is ``min(4**k, number of k-mer positions)``.
37
+ The aggregate score is the product of per-k ratios, a common explicit
38
+ linguistic-complexity definition; no DUST implementation is claimed.
39
+ """
40
+
41
+ sequence, sequence_id = sequence_and_id(value)
42
+ if sequence.topology is Topology.CIRCULAR:
43
+ raise ConfigurationError(
44
+ "Linguistic complexity currently requires a linear sequence.",
45
+ code="COMPLEXITY_CIRCULAR_UNSUPPORTED",
46
+ )
47
+ policy = coerce_ambiguity_policy(ambiguity_policy)
48
+ reject_ambiguity(sequence, policy)
49
+ validate_positive_int(max_word_size, "max_word_size")
50
+ validate_positive_int(max_observations, "max_observations")
51
+ validate_bool(cross_gaps, "cross_gaps")
52
+ if max_word_size > 16:
53
+ raise ConfigurationError(
54
+ "max_word_size cannot exceed 16.", code="COMPLEXITY_WORD_SIZE_LIMIT"
55
+ )
56
+ by_k: dict[str, float] = {}
57
+ observed_by_k: dict[str, int] = {}
58
+ possible_by_k: dict[str, int] = {}
59
+ total_observations = 0
60
+ for k in range(1, max_word_size + 1):
61
+ words: set[str] = set()
62
+ positions = 0
63
+ for fragment in fragments(sequence, cross_gaps=cross_gaps):
64
+ for run in canonical_runs(fragment):
65
+ run_positions = max(0, len(run) - k + 1)
66
+ positions += run_positions
67
+ total_observations += run_positions
68
+ if total_observations > max_observations:
69
+ raise ConfigurationError(
70
+ "Complexity calculation exceeds max_observations.",
71
+ code="COMPLEXITY_OBSERVATION_LIMIT",
72
+ )
73
+ words.update(run[offset : offset + k] for offset in range(run_positions))
74
+ possible = min(4**k, positions)
75
+ ratio = len(words) / possible if possible else 0.0
76
+ by_k[str(k)] = ratio
77
+ observed_by_k[str(k)] = len(words)
78
+ possible_by_k[str(k)] = possible
79
+ nonempty = tuple(value for key, value in by_k.items() if possible_by_k[key] > 0)
80
+ score = math.prod(nonempty) if nonempty else 0.0
81
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
82
+ return ComplexityResult(
83
+ name="linguistic_complexity",
84
+ method="vocabulary-observed-over-possible-product",
85
+ sequence_id=sequence_id,
86
+ ambiguity_policy=policy,
87
+ cross_gaps=cross_gaps,
88
+ gap_count=len(gaps),
89
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
90
+ score=score,
91
+ max_word_size=max_word_size,
92
+ by_k=FrozenDict(by_k),
93
+ observed_by_k=FrozenDict(observed_by_k),
94
+ possible_by_k=FrozenDict(possible_by_k),
95
+ observation_count=total_observations,
96
+ max_observations=max_observations,
97
+ formula="product_k(unique_kmers/min(4**k,valid_kmer_positions))",
98
+ )
99
+
100
+
101
+ def exact_repeat_fraction(
102
+ value: SequenceInput,
103
+ *,
104
+ min_unit_length: int = 1,
105
+ max_unit_length: int = 20,
106
+ min_repeats: int = 2,
107
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
108
+ cross_gaps: bool = False,
109
+ max_comparisons: int = 5_000_000,
110
+ ) -> ExactRepeatResult:
111
+ """Measure union coverage of maximal exact tandem-repeat runs."""
112
+
113
+ sequence, sequence_id = sequence_and_id(value)
114
+ if sequence.topology is Topology.CIRCULAR:
115
+ raise ConfigurationError(
116
+ "Exact tandem-repeat coverage currently requires a linear sequence.",
117
+ code="REPEAT_CIRCULAR_UNSUPPORTED",
118
+ )
119
+ policy = coerce_ambiguity_policy(ambiguity_policy)
120
+ reject_ambiguity(sequence, policy)
121
+ for name, item in (
122
+ ("min_unit_length", min_unit_length),
123
+ ("max_unit_length", max_unit_length),
124
+ ("min_repeats", min_repeats),
125
+ ("max_comparisons", max_comparisons),
126
+ ):
127
+ validate_positive_int(item, name)
128
+ validate_bool(cross_gaps, "cross_gaps")
129
+ if min_unit_length > max_unit_length or max_unit_length > 100 or min_repeats < 2:
130
+ raise ConfigurationError(
131
+ "Repeat bounds require 1 <= min_unit_length <= max_unit_length <= 100 and "
132
+ "min_repeats >= 2.",
133
+ code="INVALID_REPEAT_DESCRIPTOR_CONFIG",
134
+ )
135
+ runs: list[RepeatRun] = []
136
+ covered_positions: set[int] = set()
137
+ comparisons = 0
138
+ symbol_offset = 0
139
+ for fragment in fragments(sequence, cross_gaps=cross_gaps):
140
+ start = 0
141
+ while start < len(fragment):
142
+ found: tuple[str, int, int] | None = None
143
+ for unit_length in range(min_unit_length, max_unit_length + 1):
144
+ if start + unit_length * min_repeats > len(fragment):
145
+ continue
146
+ unit = fragment[start : start + unit_length]
147
+ if any(symbol not in "ACGT" for symbol in unit):
148
+ continue
149
+ count = 1
150
+ cursor = start + unit_length
151
+ while cursor + unit_length <= len(fragment):
152
+ comparisons += 1
153
+ if comparisons > max_comparisons:
154
+ raise ConfigurationError(
155
+ "Repeat calculation exceeds max_comparisons.",
156
+ code="REPEAT_COMPARISON_LIMIT",
157
+ )
158
+ if fragment[cursor : cursor + unit_length] != unit:
159
+ break
160
+ count += 1
161
+ cursor += unit_length
162
+ if count >= min_repeats:
163
+ found = unit, count, cursor
164
+ break
165
+ if found is None:
166
+ start += 1
167
+ continue
168
+ unit, count, end = found
169
+ absolute_start = symbol_offset + start
170
+ absolute_end = symbol_offset + end
171
+ runs.append(RepeatRun(unit, len(unit), count, absolute_start, absolute_end))
172
+ covered_positions.update(range(absolute_start, absolute_end))
173
+ start = end
174
+ symbol_offset += len(fragment)
175
+ denominator = sequence.canonical_base_count
176
+ repeat_count_by_unit = Counter(run.unit_length for run in runs)
177
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
178
+ return ExactRepeatResult(
179
+ name="exact_repeat_fraction",
180
+ method="maximal-exact-tandem-repeat-union",
181
+ sequence_id=sequence_id,
182
+ ambiguity_policy=policy,
183
+ cross_gaps=cross_gaps,
184
+ gap_count=len(gaps),
185
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
186
+ repeat_fraction=len(covered_positions) / denominator if denominator else 0.0,
187
+ repeated_base_count=len(covered_positions),
188
+ denominator=denominator,
189
+ min_unit_length=min_unit_length,
190
+ max_unit_length=max_unit_length,
191
+ min_repeats=min_repeats,
192
+ runs=tuple(runs),
193
+ repeat_count_by_unit=FrozenDict(
194
+ {str(key): value for key, value in sorted(repeat_count_by_unit.items())}
195
+ ),
196
+ comparisons=comparisons,
197
+ max_comparisons=max_comparisons,
198
+ )
199
+
200
+
201
+ __all__ = ["exact_repeat_fraction", "linguistic_complexity"]
@@ -0,0 +1,101 @@
1
+ """Native Shannon entropy descriptors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+ from collections.abc import Iterable
8
+
9
+ from dnakit.core.gap import Gap
10
+ from dnakit.descriptors._shared import (
11
+ DescriptorAmbiguityPolicy,
12
+ SequenceInput,
13
+ coerce_ambiguity_policy,
14
+ iter_kmers,
15
+ reject_ambiguity,
16
+ sequence_and_id,
17
+ validate_bool,
18
+ validate_positive_int,
19
+ )
20
+ from dnakit.descriptors.results import EntropyResult
21
+ from dnakit.exceptions import ConfigurationError
22
+
23
+
24
+ def shannon_entropy(
25
+ value: SequenceInput,
26
+ *,
27
+ unit: str = "base",
28
+ k: int = 1,
29
+ log_base: float = 2.0,
30
+ ambiguity_policy: DescriptorAmbiguityPolicy | str = DescriptorAmbiguityPolicy.ERROR,
31
+ cross_gaps: bool = False,
32
+ ) -> EntropyResult:
33
+ """Calculate Shannon entropy for canonical bases or valid k-mers.
34
+
35
+ An empty observation distribution has entropy ``0.0``. The log base must be
36
+ finite, positive, and different from one.
37
+ """
38
+
39
+ sequence, sequence_id = sequence_and_id(value)
40
+ policy = coerce_ambiguity_policy(ambiguity_policy)
41
+ reject_ambiguity(sequence, policy)
42
+ if unit not in {"base", "kmer"}:
43
+ raise ConfigurationError(
44
+ "Entropy unit must be 'base' or 'kmer'.",
45
+ context={"unit": unit},
46
+ )
47
+ validate_positive_int(k, "k")
48
+ validate_bool(cross_gaps, "cross_gaps")
49
+ if (
50
+ isinstance(log_base, bool)
51
+ or not isinstance(log_base, (int, float))
52
+ or not math.isfinite(log_base)
53
+ or log_base <= 0
54
+ or log_base == 1
55
+ ):
56
+ raise ConfigurationError(
57
+ "log_base must be finite, positive, and different from one.",
58
+ context={"log_base": log_base},
59
+ )
60
+
61
+ observations: Iterable[str]
62
+ if unit == "base":
63
+ observations = (symbol for symbol in sequence.symbols if symbol in "ACGT")
64
+ resolved_k = 1
65
+ else:
66
+ observations = iter_kmers(
67
+ sequence,
68
+ k=k,
69
+ overlapping=True,
70
+ cross_gaps=cross_gaps,
71
+ )
72
+ resolved_k = k
73
+ counts = Counter(observations)
74
+ total = sum(counts.values())
75
+ entropy = (
76
+ -sum((count / total) * math.log(count / total, log_base) for count in counts.values())
77
+ if total
78
+ else 0.0
79
+ )
80
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
81
+ return EntropyResult(
82
+ name="shannon_entropy",
83
+ method="shannon",
84
+ sequence_id=sequence_id,
85
+ ambiguity_policy=policy,
86
+ cross_gaps=cross_gaps,
87
+ gap_count=len(gaps),
88
+ unknown_gap_count=sum(gap.length is None for gap in gaps),
89
+ entropy=entropy,
90
+ unit=unit,
91
+ k=resolved_k,
92
+ log_base=float(log_base),
93
+ observation_count=total,
94
+ category_count=len(counts),
95
+ ignored_ambiguity_count=(
96
+ sequence.ambiguity_count if policy is DescriptorAmbiguityPolicy.IGNORE else 0
97
+ ),
98
+ )
99
+
100
+
101
+ __all__ = ["shannon_entropy"]