dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/ops/records.py
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
"""Feature- and letter-annotation-aware operations on immutable DNA records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
8
|
+
|
|
9
|
+
from dnakit.core import (
|
|
10
|
+
CompoundLocation,
|
|
11
|
+
DNAFeature,
|
|
12
|
+
DNARecord,
|
|
13
|
+
DNASequence,
|
|
14
|
+
Interval,
|
|
15
|
+
Location,
|
|
16
|
+
Strand,
|
|
17
|
+
UnresolvedLocation,
|
|
18
|
+
reverse_strand_location,
|
|
19
|
+
)
|
|
20
|
+
from dnakit.core._json import FrozenDict, to_json_compatible
|
|
21
|
+
from dnakit.exceptions import ConfigurationError, UnknownLengthError
|
|
22
|
+
from dnakit.ops.circular import canonical_origin, rotate
|
|
23
|
+
from dnakit.ops.edit import Edit, delete, insert, mask, substitute, trim
|
|
24
|
+
|
|
25
|
+
FeatureOverlapPolicy: TypeAlias = Literal["preserve", "truncate", "delete", "split", "unresolved"]
|
|
26
|
+
LetterAnnotationPolicy: TypeAlias = Literal["error", "drop"]
|
|
27
|
+
FeatureChangeAction: TypeAlias = Literal[
|
|
28
|
+
"preserved",
|
|
29
|
+
"shifted",
|
|
30
|
+
"resized",
|
|
31
|
+
"truncated",
|
|
32
|
+
"split",
|
|
33
|
+
"unresolved",
|
|
34
|
+
"deleted",
|
|
35
|
+
"reverse_complemented",
|
|
36
|
+
"rotated",
|
|
37
|
+
]
|
|
38
|
+
LetterAnnotationAction: TypeAlias = Literal[
|
|
39
|
+
"absent",
|
|
40
|
+
"preserved",
|
|
41
|
+
"updated",
|
|
42
|
+
"dropped",
|
|
43
|
+
"trimmed",
|
|
44
|
+
"reversed",
|
|
45
|
+
"rotated",
|
|
46
|
+
]
|
|
47
|
+
RecordOperation: TypeAlias = Literal[
|
|
48
|
+
"insert",
|
|
49
|
+
"delete",
|
|
50
|
+
"substitute",
|
|
51
|
+
"mask",
|
|
52
|
+
"trim",
|
|
53
|
+
"reverse_complement",
|
|
54
|
+
"rotate",
|
|
55
|
+
"canonical_origin",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
_FEATURE_POLICIES = frozenset({"preserve", "truncate", "delete", "split", "unresolved"})
|
|
59
|
+
_ANNOTATION_POLICIES = frozenset({"error", "drop"})
|
|
60
|
+
_FEATURE_ACTIONS = frozenset(
|
|
61
|
+
{
|
|
62
|
+
"preserved",
|
|
63
|
+
"shifted",
|
|
64
|
+
"resized",
|
|
65
|
+
"truncated",
|
|
66
|
+
"split",
|
|
67
|
+
"unresolved",
|
|
68
|
+
"deleted",
|
|
69
|
+
"reverse_complemented",
|
|
70
|
+
"rotated",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
_ANNOTATION_ACTIONS = frozenset(
|
|
74
|
+
{"absent", "preserved", "updated", "dropped", "trimmed", "reversed", "rotated"}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class FeatureChange:
|
|
80
|
+
"""One feature-coordinate decision made by a record operation."""
|
|
81
|
+
|
|
82
|
+
feature_index: int
|
|
83
|
+
feature_id: str | None
|
|
84
|
+
feature_type: str
|
|
85
|
+
action: FeatureChangeAction
|
|
86
|
+
original_location: Location
|
|
87
|
+
new_location: Location | None
|
|
88
|
+
policy: FeatureOverlapPolicy
|
|
89
|
+
affected_edit_indices: tuple[int, ...] = ()
|
|
90
|
+
reason: str | None = None
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
if (
|
|
94
|
+
isinstance(self.feature_index, bool)
|
|
95
|
+
or not isinstance(self.feature_index, int)
|
|
96
|
+
or self.feature_index < 0
|
|
97
|
+
):
|
|
98
|
+
raise ConfigurationError("FeatureChange feature_index must be non-negative.")
|
|
99
|
+
if not isinstance(self.feature_type, str) or not self.feature_type.strip():
|
|
100
|
+
raise ConfigurationError("FeatureChange feature_type must be non-empty.")
|
|
101
|
+
if self.action not in _FEATURE_ACTIONS:
|
|
102
|
+
raise ConfigurationError("FeatureChange action is invalid.")
|
|
103
|
+
if self.policy not in _FEATURE_POLICIES:
|
|
104
|
+
raise ConfigurationError("FeatureChange policy is invalid.")
|
|
105
|
+
if any(
|
|
106
|
+
isinstance(index, bool) or not isinstance(index, int) or index < 0
|
|
107
|
+
for index in self.affected_edit_indices
|
|
108
|
+
):
|
|
109
|
+
raise ConfigurationError("FeatureChange edit indices must be non-negative integers.")
|
|
110
|
+
if self.reason is not None and (
|
|
111
|
+
not isinstance(self.reason, str) or not self.reason.strip()
|
|
112
|
+
):
|
|
113
|
+
raise ConfigurationError("FeatureChange reason must be non-empty or None.")
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict[str, Any]:
|
|
116
|
+
"""Return this decision as JSON-compatible primitives."""
|
|
117
|
+
|
|
118
|
+
return cast(dict[str, Any], to_json_compatible(self))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class RecordOperationResult:
|
|
123
|
+
"""A transformed record plus complete coordinate and annotation audit."""
|
|
124
|
+
|
|
125
|
+
record: DNARecord
|
|
126
|
+
operation: RecordOperation
|
|
127
|
+
edits: tuple[Edit, ...]
|
|
128
|
+
feature_policy: FeatureOverlapPolicy
|
|
129
|
+
feature_changes: tuple[FeatureChange, ...]
|
|
130
|
+
letter_annotation_action: LetterAnnotationAction
|
|
131
|
+
parameters: FrozenDict
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
if not isinstance(self.record, DNARecord):
|
|
135
|
+
raise ConfigurationError("RecordOperationResult record must be DNARecord.")
|
|
136
|
+
if self.operation not in {
|
|
137
|
+
"insert",
|
|
138
|
+
"delete",
|
|
139
|
+
"substitute",
|
|
140
|
+
"mask",
|
|
141
|
+
"trim",
|
|
142
|
+
"reverse_complement",
|
|
143
|
+
"rotate",
|
|
144
|
+
"canonical_origin",
|
|
145
|
+
}:
|
|
146
|
+
raise ConfigurationError("RecordOperationResult operation is invalid.")
|
|
147
|
+
if any(not isinstance(edit, Edit) for edit in self.edits):
|
|
148
|
+
raise ConfigurationError("RecordOperationResult edits must contain Edit objects.")
|
|
149
|
+
if self.feature_policy not in _FEATURE_POLICIES:
|
|
150
|
+
raise ConfigurationError("RecordOperationResult feature_policy is invalid.")
|
|
151
|
+
if any(not isinstance(change, FeatureChange) for change in self.feature_changes):
|
|
152
|
+
raise ConfigurationError(
|
|
153
|
+
"RecordOperationResult feature_changes must contain FeatureChange objects."
|
|
154
|
+
)
|
|
155
|
+
if self.letter_annotation_action not in _ANNOTATION_ACTIONS:
|
|
156
|
+
raise ConfigurationError("letter_annotation_action is invalid.")
|
|
157
|
+
if not isinstance(self.parameters, FrozenDict):
|
|
158
|
+
raise ConfigurationError("RecordOperationResult parameters must be FrozenDict.")
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def sequence(self) -> DNASequence:
|
|
162
|
+
"""Convenience alias for the transformed record sequence."""
|
|
163
|
+
|
|
164
|
+
return self.record.sequence
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def deleted_feature_count(self) -> int:
|
|
168
|
+
return sum(change.action == "deleted" for change in self.feature_changes)
|
|
169
|
+
|
|
170
|
+
def to_dict(self) -> dict[str, Any]:
|
|
171
|
+
"""Return an audit payload without duplicating the full output sequence."""
|
|
172
|
+
|
|
173
|
+
payload = {
|
|
174
|
+
"record_id": self.record.id,
|
|
175
|
+
"operation": self.operation,
|
|
176
|
+
"edits": self.edits,
|
|
177
|
+
"feature_policy": self.feature_policy,
|
|
178
|
+
"feature_changes": self.feature_changes,
|
|
179
|
+
"deleted_feature_count": self.deleted_feature_count,
|
|
180
|
+
"letter_annotation_action": self.letter_annotation_action,
|
|
181
|
+
"parameters": self.parameters,
|
|
182
|
+
"output_symbol_length": self.record.sequence.symbol_length,
|
|
183
|
+
"output_coordinate_span": self.record.sequence.coordinate_span,
|
|
184
|
+
"output_topology": self.record.sequence.topology,
|
|
185
|
+
}
|
|
186
|
+
return cast(dict[str, Any], to_json_compatible(payload))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _require_record(record: DNARecord) -> DNARecord:
|
|
190
|
+
if not isinstance(record, DNARecord):
|
|
191
|
+
raise ConfigurationError(
|
|
192
|
+
"A DNARecord object is required.",
|
|
193
|
+
code="INVALID_RECORD_OPERATION_ARGUMENT",
|
|
194
|
+
context={"type": type(record).__name__},
|
|
195
|
+
)
|
|
196
|
+
return record
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _feature_policy(value: FeatureOverlapPolicy) -> FeatureOverlapPolicy:
|
|
200
|
+
if not isinstance(value, str) or value not in _FEATURE_POLICIES:
|
|
201
|
+
raise ConfigurationError(
|
|
202
|
+
"Unknown feature overlap policy.",
|
|
203
|
+
code="INVALID_FEATURE_OVERLAP_POLICY",
|
|
204
|
+
context={"feature_policy": value},
|
|
205
|
+
hint="Choose preserve, truncate, delete, split, or unresolved.",
|
|
206
|
+
)
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _annotation_policy(value: LetterAnnotationPolicy) -> LetterAnnotationPolicy:
|
|
211
|
+
if not isinstance(value, str) or value not in _ANNOTATION_POLICIES:
|
|
212
|
+
raise ConfigurationError(
|
|
213
|
+
"Unknown letter annotation policy.",
|
|
214
|
+
code="INVALID_LETTER_ANNOTATION_POLICY",
|
|
215
|
+
context={"letter_annotation_policy": value},
|
|
216
|
+
hint="Choose error or drop.",
|
|
217
|
+
)
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _copy_feature(feature: DNAFeature, location: Location) -> DNAFeature:
|
|
222
|
+
return DNAFeature(
|
|
223
|
+
feature.type,
|
|
224
|
+
location,
|
|
225
|
+
id=feature.id,
|
|
226
|
+
strand=feature.strand,
|
|
227
|
+
label=feature.label,
|
|
228
|
+
score=feature.score,
|
|
229
|
+
phase=feature.phase,
|
|
230
|
+
qualifiers=feature.qualifiers,
|
|
231
|
+
source=feature.source,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _location_parts(location: Location) -> tuple[Interval, ...]:
|
|
236
|
+
if isinstance(location, Interval):
|
|
237
|
+
return (location,)
|
|
238
|
+
if isinstance(location, CompoundLocation):
|
|
239
|
+
return location.parts
|
|
240
|
+
return location.anchors
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _resolved_location(parts: Iterable[Interval]) -> Location | None:
|
|
244
|
+
materialized = tuple(parts)
|
|
245
|
+
if not materialized:
|
|
246
|
+
return None
|
|
247
|
+
return materialized[0] if len(materialized) == 1 else CompoundLocation(materialized)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _edit_delta(edit: Edit) -> int:
|
|
251
|
+
replacement_length = sum(
|
|
252
|
+
len(part) if isinstance(part, str) else cast(int, part.length)
|
|
253
|
+
for part in edit.replacement_parts
|
|
254
|
+
)
|
|
255
|
+
return replacement_length - (edit.end - edit.start)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _replacement_length(edit: Edit) -> int:
|
|
259
|
+
return edit.end - edit.start + _edit_delta(edit)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _interval_affected(interval: Interval, edit: Edit) -> bool:
|
|
263
|
+
if edit.start == edit.end:
|
|
264
|
+
return _replacement_length(edit) > 0 and interval.start < edit.start < interval.end
|
|
265
|
+
return interval.start < edit.end and edit.start < interval.end
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _map_start(position: int, edit: Edit) -> int:
|
|
269
|
+
delta = _edit_delta(edit)
|
|
270
|
+
if position < edit.start:
|
|
271
|
+
return position
|
|
272
|
+
if position >= edit.end:
|
|
273
|
+
return position + delta
|
|
274
|
+
return edit.start
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _map_end(position: int, edit: Edit) -> int:
|
|
278
|
+
delta = _edit_delta(edit)
|
|
279
|
+
if position <= edit.start:
|
|
280
|
+
return position
|
|
281
|
+
if position >= edit.end:
|
|
282
|
+
return position + delta
|
|
283
|
+
return edit.start + _replacement_length(edit)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _preserved_interval(interval: Interval, edit: Edit) -> Interval:
|
|
287
|
+
if edit.start == edit.end:
|
|
288
|
+
delta = _edit_delta(edit)
|
|
289
|
+
if len(interval) == 0:
|
|
290
|
+
mapped = interval.start + delta if interval.start >= edit.start else interval.start
|
|
291
|
+
return Interval(mapped, mapped)
|
|
292
|
+
start = interval.start + delta if interval.start >= edit.start else interval.start
|
|
293
|
+
end = interval.end + delta if interval.end > edit.start else interval.end
|
|
294
|
+
return Interval(start, end)
|
|
295
|
+
if len(interval) == 0:
|
|
296
|
+
position = interval.start
|
|
297
|
+
delta = _edit_delta(edit)
|
|
298
|
+
if position <= edit.start:
|
|
299
|
+
mapped = position
|
|
300
|
+
elif position >= edit.end:
|
|
301
|
+
mapped = position + delta
|
|
302
|
+
else:
|
|
303
|
+
mapped = edit.start
|
|
304
|
+
return Interval(mapped, mapped)
|
|
305
|
+
return Interval(_map_start(interval.start, edit), _map_end(interval.end, edit))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _surviving_intervals(interval: Interval, edit: Edit) -> tuple[Interval, ...]:
|
|
309
|
+
survivors: list[Interval] = []
|
|
310
|
+
before_end = min(interval.end, edit.start)
|
|
311
|
+
if interval.start < before_end:
|
|
312
|
+
survivors.append(Interval(interval.start, before_end))
|
|
313
|
+
after_start = max(interval.start, edit.end)
|
|
314
|
+
if after_start < interval.end:
|
|
315
|
+
delta = _edit_delta(edit)
|
|
316
|
+
survivors.append(Interval(after_start + delta, interval.end + delta))
|
|
317
|
+
return tuple(survivors)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _sync_feature_for_edit(
|
|
321
|
+
feature: DNAFeature,
|
|
322
|
+
edit: Edit,
|
|
323
|
+
policy: FeatureOverlapPolicy,
|
|
324
|
+
) -> tuple[DNAFeature | None, FeatureChangeAction, str | None]:
|
|
325
|
+
location = feature.location
|
|
326
|
+
parts = _location_parts(location)
|
|
327
|
+
affected = any(_interval_affected(part, edit) for part in parts)
|
|
328
|
+
if isinstance(location, UnresolvedLocation):
|
|
329
|
+
mapped = tuple(_preserved_interval(anchor, edit) for anchor in parts)
|
|
330
|
+
reason = f"{location.reason}; coordinates transformed across {edit.kind}"
|
|
331
|
+
return _copy_feature(feature, UnresolvedLocation(reason, mapped)), "unresolved", reason
|
|
332
|
+
|
|
333
|
+
if not affected:
|
|
334
|
+
mapped_location = _resolved_location(_preserved_interval(part, edit) for part in parts)
|
|
335
|
+
assert mapped_location is not None
|
|
336
|
+
action: FeatureChangeAction = "preserved" if mapped_location == location else "shifted"
|
|
337
|
+
return _copy_feature(feature, mapped_location), action, None
|
|
338
|
+
|
|
339
|
+
if policy == "delete":
|
|
340
|
+
return None, "deleted", f"feature overlaps {edit.kind}"
|
|
341
|
+
|
|
342
|
+
survivors = tuple(survivor for part in parts for survivor in _surviving_intervals(part, edit))
|
|
343
|
+
if policy == "unresolved":
|
|
344
|
+
reason = f"feature overlaps {edit.kind}; exact biological extent is unresolved"
|
|
345
|
+
return _copy_feature(feature, UnresolvedLocation(reason, survivors)), "unresolved", reason
|
|
346
|
+
if policy == "truncate":
|
|
347
|
+
if not survivors:
|
|
348
|
+
return None, "deleted", f"feature is completely replaced by {edit.kind}"
|
|
349
|
+
selected = min(survivors, key=lambda part: (-len(part), part.start, part.end))
|
|
350
|
+
return _copy_feature(feature, selected), "truncated", "longest surviving segment retained"
|
|
351
|
+
if policy == "split":
|
|
352
|
+
mapped_location = _resolved_location(survivors)
|
|
353
|
+
if mapped_location is None:
|
|
354
|
+
return None, "deleted", f"feature is completely replaced by {edit.kind}"
|
|
355
|
+
action = "split" if len(survivors) > 1 else "truncated"
|
|
356
|
+
return _copy_feature(feature, mapped_location), action, "replacement coordinates excluded"
|
|
357
|
+
|
|
358
|
+
mapped_location = _resolved_location(_preserved_interval(part, edit) for part in parts)
|
|
359
|
+
assert mapped_location is not None
|
|
360
|
+
action = "preserved" if mapped_location == location else "resized"
|
|
361
|
+
return _copy_feature(feature, mapped_location), action, "replacement coordinates included"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _sync_features_for_edits(
|
|
365
|
+
features: tuple[DNAFeature, ...],
|
|
366
|
+
edits: tuple[Edit, ...],
|
|
367
|
+
policy: FeatureOverlapPolicy,
|
|
368
|
+
) -> tuple[tuple[DNAFeature, ...], tuple[FeatureChange, ...]]:
|
|
369
|
+
output: list[DNAFeature] = []
|
|
370
|
+
changes: list[FeatureChange] = []
|
|
371
|
+
indexed_edits = tuple(
|
|
372
|
+
sorted(enumerate(edits), key=lambda item: (item[1].start, item[1].end), reverse=True)
|
|
373
|
+
)
|
|
374
|
+
for feature_index, original in enumerate(features):
|
|
375
|
+
current: DNAFeature | None = original
|
|
376
|
+
actions: list[FeatureChangeAction] = []
|
|
377
|
+
reasons: list[str] = []
|
|
378
|
+
affected_indices: list[int] = []
|
|
379
|
+
for edit_index, edit in indexed_edits:
|
|
380
|
+
if current is None:
|
|
381
|
+
break
|
|
382
|
+
was_affected = any(
|
|
383
|
+
_interval_affected(part, edit) for part in _location_parts(current.location)
|
|
384
|
+
)
|
|
385
|
+
current, action, reason = _sync_feature_for_edit(current, edit, policy)
|
|
386
|
+
actions.append(action)
|
|
387
|
+
if was_affected:
|
|
388
|
+
affected_indices.append(edit_index)
|
|
389
|
+
if reason is not None:
|
|
390
|
+
reasons.append(reason)
|
|
391
|
+
if current is not None:
|
|
392
|
+
output.append(current)
|
|
393
|
+
significant = next(
|
|
394
|
+
(action for action in reversed(actions) if action not in {"preserved", "shifted"}),
|
|
395
|
+
"shifted" if "shifted" in actions else "preserved",
|
|
396
|
+
)
|
|
397
|
+
changes.append(
|
|
398
|
+
FeatureChange(
|
|
399
|
+
feature_index,
|
|
400
|
+
original.id,
|
|
401
|
+
original.type,
|
|
402
|
+
"deleted" if current is None else significant,
|
|
403
|
+
original.location,
|
|
404
|
+
None if current is None else current.location,
|
|
405
|
+
policy,
|
|
406
|
+
tuple(affected_indices),
|
|
407
|
+
"; ".join(reasons) or None,
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
return tuple(output), tuple(changes)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _symbol_boundary(sequence: DNASequence, coordinate: int) -> int:
|
|
414
|
+
coordinate_cursor = 0
|
|
415
|
+
symbol_cursor = 0
|
|
416
|
+
for part in sequence.parts:
|
|
417
|
+
if isinstance(part, str):
|
|
418
|
+
part_end = coordinate_cursor + len(part)
|
|
419
|
+
if coordinate_cursor <= coordinate <= part_end:
|
|
420
|
+
return symbol_cursor + coordinate - coordinate_cursor
|
|
421
|
+
coordinate_cursor = part_end
|
|
422
|
+
symbol_cursor += len(part)
|
|
423
|
+
else:
|
|
424
|
+
if part.length is None:
|
|
425
|
+
raise UnknownLengthError(
|
|
426
|
+
"Letter annotations cannot be synchronized across an unknown-length Gap.",
|
|
427
|
+
code="UNRESOLVED_LETTER_ANNOTATION_COORDINATES",
|
|
428
|
+
)
|
|
429
|
+
part_end = coordinate_cursor + part.length
|
|
430
|
+
if coordinate_cursor <= coordinate <= part_end:
|
|
431
|
+
return symbol_cursor
|
|
432
|
+
coordinate_cursor = part_end
|
|
433
|
+
if coordinate == coordinate_cursor:
|
|
434
|
+
return symbol_cursor
|
|
435
|
+
raise ConfigurationError(
|
|
436
|
+
"Letter annotation coordinate exceeds the sequence span.",
|
|
437
|
+
code="LETTER_ANNOTATION_COORDINATE_OUT_OF_RANGE",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _replacement_annotation_values(
|
|
442
|
+
record: DNARecord,
|
|
443
|
+
replacement_length: int,
|
|
444
|
+
replacement_annotations: Mapping[str, Iterable[int | float]] | None,
|
|
445
|
+
policy: LetterAnnotationPolicy,
|
|
446
|
+
) -> tuple[Mapping[str, tuple[int | float, ...]], str]:
|
|
447
|
+
if policy == "drop":
|
|
448
|
+
if replacement_annotations:
|
|
449
|
+
raise ConfigurationError(
|
|
450
|
+
"replacement_annotations cannot be used when annotations are dropped.",
|
|
451
|
+
code="UNEXPECTED_REPLACEMENT_ANNOTATIONS",
|
|
452
|
+
)
|
|
453
|
+
return {}, "dropped" if record.letter_annotations else "absent"
|
|
454
|
+
if not record.letter_annotations:
|
|
455
|
+
if replacement_annotations:
|
|
456
|
+
raise ConfigurationError(
|
|
457
|
+
"Cannot create partial letter annotations for an unannotated record.",
|
|
458
|
+
code="PARTIAL_REPLACEMENT_ANNOTATIONS",
|
|
459
|
+
)
|
|
460
|
+
return {}, "absent"
|
|
461
|
+
if replacement_length == 0 and replacement_annotations is None:
|
|
462
|
+
return {}, "updated"
|
|
463
|
+
if replacement_annotations is None:
|
|
464
|
+
raise ConfigurationError(
|
|
465
|
+
"Inserted or substituted symbols require replacement letter annotations.",
|
|
466
|
+
code="REPLACEMENT_ANNOTATIONS_REQUIRED",
|
|
467
|
+
context={"replacement_length": replacement_length},
|
|
468
|
+
hint="Provide every existing annotation key or choose letter_annotation_policy='drop'.",
|
|
469
|
+
)
|
|
470
|
+
if set(replacement_annotations) != set(record.letter_annotations):
|
|
471
|
+
raise ConfigurationError(
|
|
472
|
+
"Replacement annotation keys must exactly match existing annotation keys.",
|
|
473
|
+
code="REPLACEMENT_ANNOTATION_KEYS_MISMATCH",
|
|
474
|
+
context={
|
|
475
|
+
"expected": sorted(record.letter_annotations),
|
|
476
|
+
"observed": sorted(replacement_annotations),
|
|
477
|
+
},
|
|
478
|
+
)
|
|
479
|
+
resolved: dict[str, tuple[int | float, ...]] = {}
|
|
480
|
+
for name, values in replacement_annotations.items():
|
|
481
|
+
materialized = tuple(values)
|
|
482
|
+
if len(materialized) != replacement_length:
|
|
483
|
+
raise ConfigurationError(
|
|
484
|
+
"Replacement annotation length must equal replacement symbol length.",
|
|
485
|
+
code="REPLACEMENT_ANNOTATION_LENGTH_MISMATCH",
|
|
486
|
+
context={
|
|
487
|
+
"name": name,
|
|
488
|
+
"annotation_length": len(materialized),
|
|
489
|
+
"replacement_length": replacement_length,
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
resolved[name] = materialized
|
|
493
|
+
return resolved, "updated"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _edit_annotations(
|
|
497
|
+
record: DNARecord,
|
|
498
|
+
edit: Edit,
|
|
499
|
+
replacement_annotations: Mapping[str, Iterable[int | float]] | None,
|
|
500
|
+
policy: LetterAnnotationPolicy,
|
|
501
|
+
) -> tuple[Mapping[str, tuple[int | float, ...]], LetterAnnotationAction]:
|
|
502
|
+
replacement_length = len(edit.replacement_symbols)
|
|
503
|
+
replacements, action = _replacement_annotation_values(
|
|
504
|
+
record, replacement_length, replacement_annotations, policy
|
|
505
|
+
)
|
|
506
|
+
if policy == "drop" or not record.letter_annotations:
|
|
507
|
+
return {}, cast(LetterAnnotationAction, action)
|
|
508
|
+
start = _symbol_boundary(record.sequence, edit.start)
|
|
509
|
+
end = _symbol_boundary(record.sequence, edit.end)
|
|
510
|
+
return (
|
|
511
|
+
{
|
|
512
|
+
name: (*values[:start], *replacements.get(name, ()), *values[end:])
|
|
513
|
+
for name, values in record.letter_annotations.items()
|
|
514
|
+
},
|
|
515
|
+
cast(LetterAnnotationAction, action),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _result(
|
|
520
|
+
record: DNARecord,
|
|
521
|
+
sequence: DNASequence,
|
|
522
|
+
operation: RecordOperation,
|
|
523
|
+
edits: tuple[Edit, ...],
|
|
524
|
+
feature_policy: FeatureOverlapPolicy,
|
|
525
|
+
annotations: Mapping[str, Iterable[int | float]],
|
|
526
|
+
annotation_action: LetterAnnotationAction,
|
|
527
|
+
parameters: Mapping[str, object],
|
|
528
|
+
) -> RecordOperationResult:
|
|
529
|
+
features, changes = _sync_features_for_edits(record.features, edits, feature_policy)
|
|
530
|
+
transformed = DNARecord(
|
|
531
|
+
sequence,
|
|
532
|
+
record.id,
|
|
533
|
+
description=record.description,
|
|
534
|
+
features=features,
|
|
535
|
+
metadata=record.metadata,
|
|
536
|
+
letter_annotations=annotations,
|
|
537
|
+
)
|
|
538
|
+
return RecordOperationResult(
|
|
539
|
+
transformed,
|
|
540
|
+
operation,
|
|
541
|
+
edits,
|
|
542
|
+
feature_policy,
|
|
543
|
+
changes,
|
|
544
|
+
annotation_action,
|
|
545
|
+
FrozenDict(
|
|
546
|
+
{
|
|
547
|
+
**parameters,
|
|
548
|
+
"qualifiers": "preserved",
|
|
549
|
+
"feature_phase": "preserved_not_recomputed",
|
|
550
|
+
}
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def insert_record(
|
|
556
|
+
record: DNARecord,
|
|
557
|
+
position: int,
|
|
558
|
+
fragment: DNASequence | str,
|
|
559
|
+
*,
|
|
560
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
561
|
+
replacement_annotations: Mapping[str, Iterable[int | float]] | None = None,
|
|
562
|
+
letter_annotation_policy: LetterAnnotationPolicy = "error",
|
|
563
|
+
) -> RecordOperationResult:
|
|
564
|
+
"""Insert sequence and synchronize record annotations explicitly."""
|
|
565
|
+
|
|
566
|
+
source = _require_record(record)
|
|
567
|
+
policy = _feature_policy(feature_policy)
|
|
568
|
+
annotation_policy = _annotation_policy(letter_annotation_policy)
|
|
569
|
+
edited = insert(source.sequence, position, fragment)
|
|
570
|
+
annotations, action = _edit_annotations(
|
|
571
|
+
source, edited.edits[0], replacement_annotations, annotation_policy
|
|
572
|
+
)
|
|
573
|
+
return _result(
|
|
574
|
+
source,
|
|
575
|
+
edited.sequence,
|
|
576
|
+
"insert",
|
|
577
|
+
edited.edits,
|
|
578
|
+
policy,
|
|
579
|
+
annotations,
|
|
580
|
+
action,
|
|
581
|
+
{"position": position, "letter_annotation_policy": annotation_policy},
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def delete_record(
|
|
586
|
+
record: DNARecord,
|
|
587
|
+
start: int,
|
|
588
|
+
end: int,
|
|
589
|
+
*,
|
|
590
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
591
|
+
letter_annotation_policy: LetterAnnotationPolicy = "error",
|
|
592
|
+
) -> RecordOperationResult:
|
|
593
|
+
"""Delete sequence and map or resolve every affected feature."""
|
|
594
|
+
|
|
595
|
+
source = _require_record(record)
|
|
596
|
+
policy = _feature_policy(feature_policy)
|
|
597
|
+
annotation_policy = _annotation_policy(letter_annotation_policy)
|
|
598
|
+
edited = delete(source.sequence, start, end)
|
|
599
|
+
annotations, action = _edit_annotations(source, edited.edits[0], None, annotation_policy)
|
|
600
|
+
return _result(
|
|
601
|
+
source,
|
|
602
|
+
edited.sequence,
|
|
603
|
+
"delete",
|
|
604
|
+
edited.edits,
|
|
605
|
+
policy,
|
|
606
|
+
annotations,
|
|
607
|
+
action,
|
|
608
|
+
{"start": start, "end": end, "letter_annotation_policy": annotation_policy},
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def substitute_record(
|
|
613
|
+
record: DNARecord,
|
|
614
|
+
start: int,
|
|
615
|
+
end: int,
|
|
616
|
+
fragment: DNASequence | str,
|
|
617
|
+
*,
|
|
618
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
619
|
+
replacement_annotations: Mapping[str, Iterable[int | float]] | None = None,
|
|
620
|
+
letter_annotation_policy: LetterAnnotationPolicy = "error",
|
|
621
|
+
) -> RecordOperationResult:
|
|
622
|
+
"""Substitute sequence and synchronize features and letter annotations."""
|
|
623
|
+
|
|
624
|
+
source = _require_record(record)
|
|
625
|
+
policy = _feature_policy(feature_policy)
|
|
626
|
+
annotation_policy = _annotation_policy(letter_annotation_policy)
|
|
627
|
+
edited = substitute(source.sequence, start, end, fragment)
|
|
628
|
+
annotations, action = _edit_annotations(
|
|
629
|
+
source, edited.edits[0], replacement_annotations, annotation_policy
|
|
630
|
+
)
|
|
631
|
+
return _result(
|
|
632
|
+
source,
|
|
633
|
+
edited.sequence,
|
|
634
|
+
"substitute",
|
|
635
|
+
edited.edits,
|
|
636
|
+
policy,
|
|
637
|
+
annotations,
|
|
638
|
+
action,
|
|
639
|
+
{"start": start, "end": end, "letter_annotation_policy": annotation_policy},
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def mask_record(
|
|
644
|
+
record: DNARecord,
|
|
645
|
+
intervals: Iterable[tuple[int, int]],
|
|
646
|
+
*,
|
|
647
|
+
symbol: str = "N",
|
|
648
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
649
|
+
letter_annotation_policy: LetterAnnotationPolicy = "error",
|
|
650
|
+
) -> RecordOperationResult:
|
|
651
|
+
"""Mask symbols while preserving aligned letter values unless explicitly dropped."""
|
|
652
|
+
|
|
653
|
+
source = _require_record(record)
|
|
654
|
+
policy = _feature_policy(feature_policy)
|
|
655
|
+
annotation_policy = _annotation_policy(letter_annotation_policy)
|
|
656
|
+
edited = mask(source.sequence, intervals, symbol=symbol)
|
|
657
|
+
annotations: Mapping[str, Iterable[int | float]] = (
|
|
658
|
+
{} if annotation_policy == "drop" else source.letter_annotations
|
|
659
|
+
)
|
|
660
|
+
action: LetterAnnotationAction = (
|
|
661
|
+
"dropped"
|
|
662
|
+
if annotation_policy == "drop" and source.letter_annotations
|
|
663
|
+
else "preserved"
|
|
664
|
+
if source.letter_annotations
|
|
665
|
+
else "absent"
|
|
666
|
+
)
|
|
667
|
+
return _result(
|
|
668
|
+
source,
|
|
669
|
+
edited.sequence,
|
|
670
|
+
"mask",
|
|
671
|
+
edited.edits,
|
|
672
|
+
policy,
|
|
673
|
+
annotations,
|
|
674
|
+
action,
|
|
675
|
+
{"symbol": symbol, "letter_annotation_policy": annotation_policy},
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def trim_record(
|
|
680
|
+
record: DNARecord,
|
|
681
|
+
*,
|
|
682
|
+
left: int = 0,
|
|
683
|
+
right: int = 0,
|
|
684
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
685
|
+
letter_annotation_policy: LetterAnnotationPolicy = "error",
|
|
686
|
+
) -> RecordOperationResult:
|
|
687
|
+
"""Trim record ends and synchronize features in original coordinates."""
|
|
688
|
+
|
|
689
|
+
source = _require_record(record)
|
|
690
|
+
policy = _feature_policy(feature_policy)
|
|
691
|
+
annotation_policy = _annotation_policy(letter_annotation_policy)
|
|
692
|
+
edited = trim(source.sequence, left=left, right=right)
|
|
693
|
+
if annotation_policy == "drop":
|
|
694
|
+
annotations: Mapping[str, Iterable[int | float]] = {}
|
|
695
|
+
action: LetterAnnotationAction = "dropped" if source.letter_annotations else "absent"
|
|
696
|
+
elif source.letter_annotations:
|
|
697
|
+
span = source.sequence.coordinate_span
|
|
698
|
+
assert span is not None
|
|
699
|
+
symbol_start = _symbol_boundary(source.sequence, left)
|
|
700
|
+
symbol_end = _symbol_boundary(source.sequence, span - right)
|
|
701
|
+
annotations = {
|
|
702
|
+
name: values[symbol_start:symbol_end]
|
|
703
|
+
for name, values in source.letter_annotations.items()
|
|
704
|
+
}
|
|
705
|
+
action = "trimmed" if left or right else "preserved"
|
|
706
|
+
else:
|
|
707
|
+
annotations = {}
|
|
708
|
+
action = "absent"
|
|
709
|
+
return _result(
|
|
710
|
+
source,
|
|
711
|
+
edited.sequence,
|
|
712
|
+
"trim",
|
|
713
|
+
edited.edits,
|
|
714
|
+
policy,
|
|
715
|
+
annotations,
|
|
716
|
+
action,
|
|
717
|
+
{
|
|
718
|
+
"left": left,
|
|
719
|
+
"right": right,
|
|
720
|
+
"letter_annotation_policy": annotation_policy,
|
|
721
|
+
"edit_coordinate_space": "original_record",
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _reverse_strand(strand: Strand) -> Strand:
|
|
727
|
+
if strand is Strand.FORWARD:
|
|
728
|
+
return Strand.REVERSE
|
|
729
|
+
if strand is Strand.REVERSE:
|
|
730
|
+
return Strand.FORWARD
|
|
731
|
+
return strand
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _copy_feature_with_strand(
|
|
735
|
+
feature: DNAFeature, location: Location, strand: Strand
|
|
736
|
+
) -> DNAFeature:
|
|
737
|
+
return DNAFeature(
|
|
738
|
+
feature.type,
|
|
739
|
+
location,
|
|
740
|
+
id=feature.id,
|
|
741
|
+
strand=strand,
|
|
742
|
+
label=feature.label,
|
|
743
|
+
score=feature.score,
|
|
744
|
+
phase=feature.phase,
|
|
745
|
+
qualifiers=feature.qualifiers,
|
|
746
|
+
source=feature.source,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def reverse_complement_record(
|
|
751
|
+
record: DNARecord,
|
|
752
|
+
*,
|
|
753
|
+
feature_policy: FeatureOverlapPolicy = "preserve",
|
|
754
|
+
) -> RecordOperationResult:
|
|
755
|
+
"""Reverse-complement sequence, locations, strands, and letter arrays."""
|
|
756
|
+
|
|
757
|
+
source = _require_record(record)
|
|
758
|
+
policy = _feature_policy(feature_policy)
|
|
759
|
+
span = source.sequence.coordinate_span
|
|
760
|
+
output_features: list[DNAFeature] = []
|
|
761
|
+
changes: list[FeatureChange] = []
|
|
762
|
+
for index, feature in enumerate(source.features):
|
|
763
|
+
if span is None:
|
|
764
|
+
if policy == "delete":
|
|
765
|
+
changes.append(
|
|
766
|
+
FeatureChange(
|
|
767
|
+
index,
|
|
768
|
+
feature.id,
|
|
769
|
+
feature.type,
|
|
770
|
+
"deleted",
|
|
771
|
+
feature.location,
|
|
772
|
+
None,
|
|
773
|
+
policy,
|
|
774
|
+
reason="unknown sequence span prevents reverse coordinate mapping",
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
continue
|
|
778
|
+
if policy != "unresolved":
|
|
779
|
+
raise UnknownLengthError(
|
|
780
|
+
"Feature coordinates cannot be reverse-complemented across an unknown Gap.",
|
|
781
|
+
code="UNRESOLVED_REVERSE_FEATURE_COORDINATES",
|
|
782
|
+
context={"feature_index": index},
|
|
783
|
+
hint="Choose feature_policy='unresolved' or 'delete'.",
|
|
784
|
+
)
|
|
785
|
+
location: Location = UnresolvedLocation("reverse-complement coordinate span is unknown")
|
|
786
|
+
action: FeatureChangeAction = "unresolved"
|
|
787
|
+
else:
|
|
788
|
+
location = reverse_strand_location(feature.location, sequence_length=span)
|
|
789
|
+
action = "reverse_complemented"
|
|
790
|
+
mapped = _copy_feature_with_strand(feature, location, _reverse_strand(feature.strand))
|
|
791
|
+
output_features.append(mapped)
|
|
792
|
+
changes.append(
|
|
793
|
+
FeatureChange(
|
|
794
|
+
index,
|
|
795
|
+
feature.id,
|
|
796
|
+
feature.type,
|
|
797
|
+
action,
|
|
798
|
+
feature.location,
|
|
799
|
+
location,
|
|
800
|
+
policy,
|
|
801
|
+
)
|
|
802
|
+
)
|
|
803
|
+
transformed = DNARecord(
|
|
804
|
+
source.sequence.reverse_complement(),
|
|
805
|
+
source.id,
|
|
806
|
+
description=source.description,
|
|
807
|
+
features=output_features,
|
|
808
|
+
metadata=source.metadata,
|
|
809
|
+
letter_annotations={
|
|
810
|
+
name: tuple(reversed(values)) for name, values in source.letter_annotations.items()
|
|
811
|
+
},
|
|
812
|
+
)
|
|
813
|
+
return RecordOperationResult(
|
|
814
|
+
transformed,
|
|
815
|
+
"reverse_complement",
|
|
816
|
+
(),
|
|
817
|
+
policy,
|
|
818
|
+
tuple(changes),
|
|
819
|
+
"reversed" if source.letter_annotations else "absent",
|
|
820
|
+
FrozenDict(
|
|
821
|
+
{
|
|
822
|
+
"strand_mapping": "forward<->reverse; both/unknown unchanged",
|
|
823
|
+
"qualifiers": "preserved",
|
|
824
|
+
"feature_phase": "preserved_not_recomputed",
|
|
825
|
+
}
|
|
826
|
+
),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _rotate_interval(interval: Interval, offset: int, span: int) -> tuple[Interval, ...]:
|
|
831
|
+
length = len(interval)
|
|
832
|
+
start = (interval.start - offset) % span
|
|
833
|
+
if length == 0:
|
|
834
|
+
return (Interval(start, start),)
|
|
835
|
+
if length == span:
|
|
836
|
+
return (Interval(0, span),)
|
|
837
|
+
end = start + length
|
|
838
|
+
if end <= span:
|
|
839
|
+
return (Interval(start, end),)
|
|
840
|
+
return (Interval(start, span), Interval(0, end - span))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _rotate_feature(
|
|
844
|
+
feature: DNAFeature,
|
|
845
|
+
offset: int,
|
|
846
|
+
span: int,
|
|
847
|
+
policy: FeatureOverlapPolicy,
|
|
848
|
+
) -> tuple[DNAFeature | None, FeatureChangeAction, str | None]:
|
|
849
|
+
if isinstance(feature.location, UnresolvedLocation):
|
|
850
|
+
anchors = tuple(
|
|
851
|
+
mapped
|
|
852
|
+
for anchor in feature.location.anchors
|
|
853
|
+
for mapped in _rotate_interval(anchor, offset, span)
|
|
854
|
+
)
|
|
855
|
+
unresolved_location: Location = UnresolvedLocation(feature.location.reason, anchors)
|
|
856
|
+
return _copy_feature(feature, unresolved_location), "unresolved", None
|
|
857
|
+
source_parts = _location_parts(feature.location)
|
|
858
|
+
mapped_groups = tuple(_rotate_interval(part, offset, span) for part in source_parts)
|
|
859
|
+
mapped = tuple(transformed for group in mapped_groups for transformed in group)
|
|
860
|
+
wrapped = any(len(group) > 1 for group in mapped_groups)
|
|
861
|
+
if wrapped and policy == "delete":
|
|
862
|
+
return None, "deleted", "feature crosses the new circular origin"
|
|
863
|
+
if wrapped and policy == "unresolved":
|
|
864
|
+
reason = "feature crosses the new circular origin"
|
|
865
|
+
return _copy_feature(feature, UnresolvedLocation(reason, mapped)), "unresolved", reason
|
|
866
|
+
if wrapped and policy == "truncate":
|
|
867
|
+
selected = min(mapped, key=lambda part: (-len(part), part.start, part.end))
|
|
868
|
+
return _copy_feature(feature, selected), "truncated", "longest origin-side retained"
|
|
869
|
+
resolved_location = _resolved_location(mapped)
|
|
870
|
+
assert resolved_location is not None
|
|
871
|
+
action: FeatureChangeAction = (
|
|
872
|
+
"split" if wrapped and policy == "split" else "preserved" if wrapped else "rotated"
|
|
873
|
+
)
|
|
874
|
+
return (
|
|
875
|
+
_copy_feature(feature, resolved_location),
|
|
876
|
+
action,
|
|
877
|
+
"origin-spanning feature represented as CompoundLocation" if wrapped else None,
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _rotation_symbol_offset(sequence: DNASequence, coordinate: int) -> int:
|
|
882
|
+
return _symbol_boundary(sequence, coordinate)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _rotate_record(
|
|
886
|
+
source: DNARecord,
|
|
887
|
+
*,
|
|
888
|
+
offset: int,
|
|
889
|
+
operation: Literal["rotate", "canonical_origin"],
|
|
890
|
+
requested_offset: int | None,
|
|
891
|
+
feature_policy: FeatureOverlapPolicy,
|
|
892
|
+
) -> RecordOperationResult:
|
|
893
|
+
rotated = rotate(source.sequence, offset)
|
|
894
|
+
policy = _feature_policy(feature_policy)
|
|
895
|
+
features: list[DNAFeature] = []
|
|
896
|
+
changes: list[FeatureChange] = []
|
|
897
|
+
for index, feature in enumerate(source.features):
|
|
898
|
+
mapped, action, reason = _rotate_feature(
|
|
899
|
+
feature, rotated.effective_offset, rotated.sequence_span, policy
|
|
900
|
+
)
|
|
901
|
+
if mapped is not None:
|
|
902
|
+
features.append(mapped)
|
|
903
|
+
changes.append(
|
|
904
|
+
FeatureChange(
|
|
905
|
+
index,
|
|
906
|
+
feature.id,
|
|
907
|
+
feature.type,
|
|
908
|
+
action,
|
|
909
|
+
feature.location,
|
|
910
|
+
None if mapped is None else mapped.location,
|
|
911
|
+
policy,
|
|
912
|
+
reason=reason,
|
|
913
|
+
)
|
|
914
|
+
)
|
|
915
|
+
symbol_offset = _rotation_symbol_offset(source.sequence, rotated.effective_offset)
|
|
916
|
+
annotations = {
|
|
917
|
+
name: (*values[symbol_offset:], *values[:symbol_offset])
|
|
918
|
+
for name, values in source.letter_annotations.items()
|
|
919
|
+
}
|
|
920
|
+
transformed = DNARecord(
|
|
921
|
+
rotated.sequence,
|
|
922
|
+
source.id,
|
|
923
|
+
description=source.description,
|
|
924
|
+
features=features,
|
|
925
|
+
metadata=source.metadata,
|
|
926
|
+
letter_annotations=annotations,
|
|
927
|
+
)
|
|
928
|
+
origin_rule = (
|
|
929
|
+
"lexicographically_minimal_forward_rotation_then_smallest_offset"
|
|
930
|
+
if operation == "canonical_origin"
|
|
931
|
+
else rotated.rule
|
|
932
|
+
)
|
|
933
|
+
operation_parameters: dict[str, object] = {}
|
|
934
|
+
if operation == "canonical_origin":
|
|
935
|
+
operation_parameters = {
|
|
936
|
+
"comparison": "normalized_symbol_code_point_order",
|
|
937
|
+
"orientation": "forward_only",
|
|
938
|
+
"tie_break": "smallest_offset",
|
|
939
|
+
"algorithm": "booth",
|
|
940
|
+
}
|
|
941
|
+
return RecordOperationResult(
|
|
942
|
+
transformed,
|
|
943
|
+
operation,
|
|
944
|
+
(),
|
|
945
|
+
policy,
|
|
946
|
+
tuple(changes),
|
|
947
|
+
"rotated" if annotations else "absent",
|
|
948
|
+
FrozenDict(
|
|
949
|
+
{
|
|
950
|
+
"requested_offset": requested_offset,
|
|
951
|
+
"effective_offset": rotated.effective_offset,
|
|
952
|
+
"sequence_span": rotated.sequence_span,
|
|
953
|
+
"origin_rule": origin_rule,
|
|
954
|
+
**operation_parameters,
|
|
955
|
+
"qualifiers": "preserved",
|
|
956
|
+
"feature_phase": "preserved_not_recomputed",
|
|
957
|
+
}
|
|
958
|
+
),
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def rotate_record(
|
|
963
|
+
record: DNARecord,
|
|
964
|
+
offset: int,
|
|
965
|
+
*,
|
|
966
|
+
feature_policy: FeatureOverlapPolicy = "split",
|
|
967
|
+
) -> RecordOperationResult:
|
|
968
|
+
"""Rotate a circular record and map origin-spanning feature locations."""
|
|
969
|
+
|
|
970
|
+
return _rotate_record(
|
|
971
|
+
_require_record(record),
|
|
972
|
+
offset=offset,
|
|
973
|
+
operation="rotate",
|
|
974
|
+
requested_offset=offset,
|
|
975
|
+
feature_policy=feature_policy,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def canonical_origin_record(
|
|
980
|
+
record: DNARecord,
|
|
981
|
+
*,
|
|
982
|
+
feature_policy: FeatureOverlapPolicy = "split",
|
|
983
|
+
) -> RecordOperationResult:
|
|
984
|
+
"""Apply the canonical forward lexicographic origin to a record."""
|
|
985
|
+
|
|
986
|
+
source = _require_record(record)
|
|
987
|
+
canonical = canonical_origin(source.sequence)
|
|
988
|
+
return _rotate_record(
|
|
989
|
+
source,
|
|
990
|
+
offset=canonical.effective_offset,
|
|
991
|
+
operation="canonical_origin",
|
|
992
|
+
requested_offset=None,
|
|
993
|
+
feature_policy=feature_policy,
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
__all__ = [
|
|
998
|
+
"FeatureChange",
|
|
999
|
+
"FeatureChangeAction",
|
|
1000
|
+
"FeatureOverlapPolicy",
|
|
1001
|
+
"LetterAnnotationAction",
|
|
1002
|
+
"LetterAnnotationPolicy",
|
|
1003
|
+
"RecordOperationResult",
|
|
1004
|
+
"canonical_origin_record",
|
|
1005
|
+
"delete_record",
|
|
1006
|
+
"insert_record",
|
|
1007
|
+
"mask_record",
|
|
1008
|
+
"reverse_complement_record",
|
|
1009
|
+
"rotate_record",
|
|
1010
|
+
"substitute_record",
|
|
1011
|
+
"trim_record",
|
|
1012
|
+
]
|