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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Atomic persistence for standalone SVG visualization artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import TracebackType
|
|
14
|
+
from typing import BinaryIO, Protocol, TypeAlias, cast
|
|
15
|
+
|
|
16
|
+
from dnakit.core.provenance import ArtifactRef
|
|
17
|
+
from dnakit.exceptions import BackendUnavailableError, ConfigurationError
|
|
18
|
+
|
|
19
|
+
from .config import ImageExportConfig, SaveConfig
|
|
20
|
+
from .results import HTMLReportArtifact, SVGArtifact, VisualizationSaveResult
|
|
21
|
+
|
|
22
|
+
PathLike: TypeAlias = str | os.PathLike[str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _CairoSVGModule(Protocol):
|
|
26
|
+
def svg2pdf(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
bytestring: bytes,
|
|
30
|
+
dpi: int,
|
|
31
|
+
output_width: int,
|
|
32
|
+
output_height: int,
|
|
33
|
+
) -> bytes: ...
|
|
34
|
+
|
|
35
|
+
def svg2png(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
bytestring: bytes,
|
|
39
|
+
dpi: int,
|
|
40
|
+
output_width: int,
|
|
41
|
+
output_height: int,
|
|
42
|
+
) -> bytes: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _PILImage(Protocol):
|
|
46
|
+
def __enter__(self) -> _PILImage: ...
|
|
47
|
+
|
|
48
|
+
def __exit__(
|
|
49
|
+
self,
|
|
50
|
+
exception_type: type[BaseException] | None,
|
|
51
|
+
exception: BaseException | None,
|
|
52
|
+
traceback: TracebackType | None,
|
|
53
|
+
) -> bool | None: ...
|
|
54
|
+
|
|
55
|
+
def save(self, stream: BinaryIO, *, format: str, **parameters: object) -> None: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _PILImageModule(Protocol):
|
|
59
|
+
def open(self, stream: BinaryIO) -> _PILImage: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _artifact_ref(
|
|
63
|
+
path: Path,
|
|
64
|
+
*,
|
|
65
|
+
sha256: str,
|
|
66
|
+
byte_size: int,
|
|
67
|
+
media_type: str,
|
|
68
|
+
schema_version: str,
|
|
69
|
+
) -> ArtifactRef:
|
|
70
|
+
stat = path.stat()
|
|
71
|
+
return ArtifactRef(
|
|
72
|
+
relative_path=os.path.relpath(path.resolve(), Path.cwd()),
|
|
73
|
+
media_type=media_type,
|
|
74
|
+
schema_version=schema_version,
|
|
75
|
+
sha256=sha256,
|
|
76
|
+
byte_size=byte_size,
|
|
77
|
+
created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _atomic_write(path: Path, payload: bytes, *, overwrite: bool, create_parents: bool) -> bool:
|
|
82
|
+
parent = path.parent
|
|
83
|
+
if not parent.exists():
|
|
84
|
+
if create_parents:
|
|
85
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
else:
|
|
87
|
+
raise FileNotFoundError(f"Output parent directory does not exist: {parent}")
|
|
88
|
+
if not parent.is_dir():
|
|
89
|
+
raise NotADirectoryError(f"Output parent is not a directory: {parent}")
|
|
90
|
+
existed = path.exists()
|
|
91
|
+
if existed and not overwrite:
|
|
92
|
+
raise FileExistsError(f"Refusing to overwrite existing output: {path}")
|
|
93
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
94
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=parent
|
|
95
|
+
)
|
|
96
|
+
temporary_path = Path(temporary_name)
|
|
97
|
+
try:
|
|
98
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
99
|
+
stream.write(payload)
|
|
100
|
+
stream.flush()
|
|
101
|
+
os.fsync(stream.fileno())
|
|
102
|
+
if overwrite:
|
|
103
|
+
os.replace(temporary_path, path)
|
|
104
|
+
else:
|
|
105
|
+
os.link(temporary_path, path)
|
|
106
|
+
temporary_path.unlink()
|
|
107
|
+
except BaseException:
|
|
108
|
+
with suppress(OSError):
|
|
109
|
+
os.close(descriptor)
|
|
110
|
+
temporary_path.unlink(missing_ok=True)
|
|
111
|
+
raise
|
|
112
|
+
return existed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def save_svg(
|
|
116
|
+
artifact: SVGArtifact,
|
|
117
|
+
target: PathLike,
|
|
118
|
+
*,
|
|
119
|
+
config: SaveConfig | None = None,
|
|
120
|
+
) -> VisualizationSaveResult:
|
|
121
|
+
"""Atomically save exact SVG bytes, refusing overwrite by default."""
|
|
122
|
+
|
|
123
|
+
if not isinstance(artifact, SVGArtifact):
|
|
124
|
+
raise TypeError("artifact must be SVGArtifact.")
|
|
125
|
+
if not isinstance(target, (str, os.PathLike)):
|
|
126
|
+
raise TypeError("target must be a string or path-like object.")
|
|
127
|
+
resolved = SaveConfig() if config is None else config
|
|
128
|
+
if not isinstance(resolved, SaveConfig):
|
|
129
|
+
raise TypeError("config must be SaveConfig or None.")
|
|
130
|
+
path = Path(target)
|
|
131
|
+
if path.suffix.lower() != ".svg":
|
|
132
|
+
raise ConfigurationError(
|
|
133
|
+
"SVG output path must end with .svg.",
|
|
134
|
+
code="INVALID_VISUALIZATION_FORMAT",
|
|
135
|
+
context={"target": str(path)},
|
|
136
|
+
)
|
|
137
|
+
payload = artifact.svg.encode("utf-8")
|
|
138
|
+
existed = _atomic_write(
|
|
139
|
+
path,
|
|
140
|
+
payload,
|
|
141
|
+
overwrite=resolved.overwrite,
|
|
142
|
+
create_parents=resolved.create_parents,
|
|
143
|
+
)
|
|
144
|
+
target_artifact = _artifact_ref(
|
|
145
|
+
path,
|
|
146
|
+
sha256=artifact.sha256,
|
|
147
|
+
byte_size=len(payload),
|
|
148
|
+
media_type="image/svg+xml",
|
|
149
|
+
schema_version="dnakit-visualization-v1",
|
|
150
|
+
)
|
|
151
|
+
return VisualizationSaveResult(
|
|
152
|
+
artifact.sha256,
|
|
153
|
+
target_artifact,
|
|
154
|
+
overwritten=existed,
|
|
155
|
+
provenance=artifact.provenance,
|
|
156
|
+
issues=artifact.issues,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _target_dimensions(artifact: SVGArtifact, config: ImageExportConfig) -> tuple[int, int]:
|
|
161
|
+
if config.width is None and config.height is None:
|
|
162
|
+
scale = config.dpi / 96.0
|
|
163
|
+
width = max(1, round(artifact.width * scale))
|
|
164
|
+
height = max(1, round(artifact.height * scale))
|
|
165
|
+
elif config.width is None:
|
|
166
|
+
assert config.height is not None
|
|
167
|
+
height = config.height
|
|
168
|
+
width = max(1, round(artifact.width * height / artifact.height))
|
|
169
|
+
elif config.height is None:
|
|
170
|
+
width = config.width
|
|
171
|
+
height = max(1, round(artifact.height * width / artifact.width))
|
|
172
|
+
else:
|
|
173
|
+
width, height = config.width, config.height
|
|
174
|
+
if width * height > config.max_output_pixels:
|
|
175
|
+
raise ConfigurationError(
|
|
176
|
+
"Rendered image exceeds max_output_pixels.",
|
|
177
|
+
code="VISUALIZATION_PIXEL_LIMIT",
|
|
178
|
+
context={
|
|
179
|
+
"width": width,
|
|
180
|
+
"height": height,
|
|
181
|
+
"max_output_pixels": config.max_output_pixels,
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
return width, height
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _svg_payload(artifact: SVGArtifact, *, transparent: bool) -> bytes:
|
|
188
|
+
if not transparent:
|
|
189
|
+
return artifact.svg.encode("utf-8")
|
|
190
|
+
import xml.etree.ElementTree as ET
|
|
191
|
+
|
|
192
|
+
root = ET.fromstring(artifact.svg)
|
|
193
|
+
for child in tuple(root):
|
|
194
|
+
if child.tag.endswith("rect") and child.attrib.get("class") == "background":
|
|
195
|
+
root.remove(child)
|
|
196
|
+
break
|
|
197
|
+
return cast(bytes, ET.tostring(root, encoding="utf-8", xml_declaration=False))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _convert_image(
|
|
201
|
+
artifact: SVGArtifact, output_format: str, config: ImageExportConfig
|
|
202
|
+
) -> tuple[bytes, int, int]:
|
|
203
|
+
width, height = _target_dimensions(artifact, config)
|
|
204
|
+
try:
|
|
205
|
+
cairosvg = cast(_CairoSVGModule, importlib.import_module("cairosvg"))
|
|
206
|
+
except ImportError as exc:
|
|
207
|
+
raise BackendUnavailableError(
|
|
208
|
+
"PNG, TIFF, and PDF export requires the optional visualization backend.",
|
|
209
|
+
code="VISUALIZATION_BACKEND_UNAVAILABLE",
|
|
210
|
+
hint="Install DNAKit with the 'viz' extra.",
|
|
211
|
+
) from exc
|
|
212
|
+
source = _svg_payload(artifact, transparent=config.transparent)
|
|
213
|
+
if output_format == "pdf":
|
|
214
|
+
payload = cairosvg.svg2pdf(
|
|
215
|
+
bytestring=source,
|
|
216
|
+
dpi=config.dpi,
|
|
217
|
+
output_width=width,
|
|
218
|
+
output_height=height,
|
|
219
|
+
)
|
|
220
|
+
return payload, width, height
|
|
221
|
+
png = cairosvg.svg2png(
|
|
222
|
+
bytestring=source,
|
|
223
|
+
dpi=config.dpi,
|
|
224
|
+
output_width=width,
|
|
225
|
+
output_height=height,
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
image_module = cast(_PILImageModule, importlib.import_module("PIL.Image"))
|
|
229
|
+
except ImportError as exc:
|
|
230
|
+
raise BackendUnavailableError(
|
|
231
|
+
"PNG and TIFF export require Pillow from the optional visualization extra.",
|
|
232
|
+
code="VISUALIZATION_BACKEND_UNAVAILABLE",
|
|
233
|
+
hint="Install DNAKit with the 'viz' extra.",
|
|
234
|
+
) from exc
|
|
235
|
+
output = BytesIO()
|
|
236
|
+
with image_module.open(BytesIO(png)) as image:
|
|
237
|
+
if output_format == "png":
|
|
238
|
+
image.save(output, format="PNG", dpi=(config.dpi, config.dpi))
|
|
239
|
+
else:
|
|
240
|
+
image.save(output, format="TIFF", compression="tiff_lzw", dpi=(config.dpi, config.dpi))
|
|
241
|
+
return output.getvalue(), width, height
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def save_image(
|
|
245
|
+
artifact: SVGArtifact,
|
|
246
|
+
target: PathLike,
|
|
247
|
+
*,
|
|
248
|
+
config: ImageExportConfig | None = None,
|
|
249
|
+
) -> VisualizationSaveResult:
|
|
250
|
+
"""Atomically export an SVG artifact as SVG, PNG, TIFF, or PDF."""
|
|
251
|
+
|
|
252
|
+
if not isinstance(artifact, SVGArtifact):
|
|
253
|
+
raise TypeError("artifact must be SVGArtifact.")
|
|
254
|
+
if not isinstance(target, (str, os.PathLike)):
|
|
255
|
+
raise TypeError("target must be a string or path-like object.")
|
|
256
|
+
resolved = ImageExportConfig() if config is None else config
|
|
257
|
+
if not isinstance(resolved, ImageExportConfig):
|
|
258
|
+
raise TypeError("config must be ImageExportConfig or None.")
|
|
259
|
+
path = Path(target)
|
|
260
|
+
suffix = path.suffix.lower()
|
|
261
|
+
format_by_suffix = {
|
|
262
|
+
".svg": "svg",
|
|
263
|
+
".png": "png",
|
|
264
|
+
".tif": "tiff",
|
|
265
|
+
".tiff": "tiff",
|
|
266
|
+
".pdf": "pdf",
|
|
267
|
+
}
|
|
268
|
+
output_format = format_by_suffix.get(suffix)
|
|
269
|
+
if output_format is None:
|
|
270
|
+
raise ConfigurationError(
|
|
271
|
+
"Visualization output must end with .svg, .png, .tif, .tiff, or .pdf.",
|
|
272
|
+
code="INVALID_VISUALIZATION_FORMAT",
|
|
273
|
+
)
|
|
274
|
+
if output_format == "svg":
|
|
275
|
+
return save_svg(
|
|
276
|
+
artifact,
|
|
277
|
+
path,
|
|
278
|
+
config=SaveConfig(overwrite=resolved.overwrite, create_parents=resolved.create_parents),
|
|
279
|
+
)
|
|
280
|
+
payload, _width, _height = _convert_image(artifact, output_format, resolved)
|
|
281
|
+
existed = _atomic_write(
|
|
282
|
+
path,
|
|
283
|
+
payload,
|
|
284
|
+
overwrite=resolved.overwrite,
|
|
285
|
+
create_parents=resolved.create_parents,
|
|
286
|
+
)
|
|
287
|
+
media_type = {"png": "image/png", "tiff": "image/tiff", "pdf": "application/pdf"}[output_format]
|
|
288
|
+
digest = hashlib.sha256(payload).hexdigest()
|
|
289
|
+
target_artifact = _artifact_ref(
|
|
290
|
+
path,
|
|
291
|
+
sha256=digest,
|
|
292
|
+
byte_size=len(payload),
|
|
293
|
+
media_type=media_type,
|
|
294
|
+
schema_version="dnakit-visualization-export-v1",
|
|
295
|
+
)
|
|
296
|
+
return VisualizationSaveResult(
|
|
297
|
+
artifact.sha256,
|
|
298
|
+
target_artifact,
|
|
299
|
+
format=output_format,
|
|
300
|
+
overwritten=existed,
|
|
301
|
+
provenance=artifact.provenance,
|
|
302
|
+
issues=artifact.issues,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def save_html_report(
|
|
307
|
+
artifact: HTMLReportArtifact,
|
|
308
|
+
target: PathLike,
|
|
309
|
+
*,
|
|
310
|
+
config: SaveConfig | None = None,
|
|
311
|
+
) -> VisualizationSaveResult:
|
|
312
|
+
"""Atomically persist a self-contained DNAKit HTML report."""
|
|
313
|
+
|
|
314
|
+
if not isinstance(artifact, HTMLReportArtifact):
|
|
315
|
+
raise TypeError("artifact must be HTMLReportArtifact.")
|
|
316
|
+
resolved = SaveConfig() if config is None else config
|
|
317
|
+
if not isinstance(resolved, SaveConfig):
|
|
318
|
+
raise TypeError("config must be SaveConfig or None.")
|
|
319
|
+
path = Path(target)
|
|
320
|
+
if path.suffix.lower() not in {".html", ".htm"}:
|
|
321
|
+
raise ConfigurationError(
|
|
322
|
+
"HTML report path must end with .html or .htm.",
|
|
323
|
+
code="INVALID_VISUALIZATION_FORMAT",
|
|
324
|
+
)
|
|
325
|
+
payload = artifact.html.encode("utf-8")
|
|
326
|
+
existed = _atomic_write(
|
|
327
|
+
path,
|
|
328
|
+
payload,
|
|
329
|
+
overwrite=resolved.overwrite,
|
|
330
|
+
create_parents=resolved.create_parents,
|
|
331
|
+
)
|
|
332
|
+
target_artifact = _artifact_ref(
|
|
333
|
+
path,
|
|
334
|
+
sha256=artifact.sha256,
|
|
335
|
+
byte_size=len(payload),
|
|
336
|
+
media_type="text/html",
|
|
337
|
+
schema_version="dnakit-html-report-v1",
|
|
338
|
+
)
|
|
339
|
+
return VisualizationSaveResult(
|
|
340
|
+
artifact.sha256,
|
|
341
|
+
target_artifact,
|
|
342
|
+
format="html",
|
|
343
|
+
overwritten=existed,
|
|
344
|
+
provenance=artifact.provenance,
|
|
345
|
+
issues=artifact.issues,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
__all__ = ["PathLike", "save_html_report", "save_image", "save_svg"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Deterministic SVG heatmaps for precomputed similarity matrices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from dnakit.core.enums import IssueSeverity
|
|
8
|
+
from dnakit.core.issues import Issue
|
|
9
|
+
from dnakit.exceptions import ConfigurationError
|
|
10
|
+
from dnakit.similarity.results import SimilarityMatrixResult
|
|
11
|
+
|
|
12
|
+
from ._svg import SVGBuilder, interpolate_color, number, validate_xml_text
|
|
13
|
+
from .config import HeatmapConfig, LimitPolicy
|
|
14
|
+
from .results import SVGArtifact
|
|
15
|
+
|
|
16
|
+
_MAX_LABEL_LENGTH = 1_024
|
|
17
|
+
_DISPLAY_LABEL_LENGTH = 32
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_labels(labels: tuple[str, ...]) -> None:
|
|
21
|
+
for index, label in enumerate(labels):
|
|
22
|
+
if len(label) > _MAX_LABEL_LENGTH:
|
|
23
|
+
raise ConfigurationError(
|
|
24
|
+
"A matrix label exceeds the visualization safety limit.",
|
|
25
|
+
code="VISUALIZATION_SIZE_LIMIT",
|
|
26
|
+
context={"label_index": index, "max_label_length": _MAX_LABEL_LENGTH},
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
validate_xml_text(label, name="matrix label")
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise ConfigurationError(
|
|
32
|
+
"A matrix label contains a character forbidden by XML.",
|
|
33
|
+
code="INVALID_VISUALIZATION_LABEL",
|
|
34
|
+
context={"label_index": index},
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _short_label(value: str) -> str:
|
|
39
|
+
if len(value) <= _DISPLAY_LABEL_LENGTH:
|
|
40
|
+
return value
|
|
41
|
+
return value[: _DISPLAY_LABEL_LENGTH - 1] + "…"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _stride_indices(length: int, maximum: int) -> tuple[int, ...]:
|
|
45
|
+
if length <= maximum:
|
|
46
|
+
return tuple(range(length))
|
|
47
|
+
step = math.ceil(length / maximum)
|
|
48
|
+
return tuple(range(0, length, step))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _scale(values: tuple[tuple[float, ...], ...], config: HeatmapConfig) -> tuple[float, float]:
|
|
52
|
+
flat = tuple(value for row in values for value in row)
|
|
53
|
+
data_min = min(flat, default=0.0)
|
|
54
|
+
data_max = max(flat, default=1.0)
|
|
55
|
+
lower = data_min if config.value_min is None else float(config.value_min)
|
|
56
|
+
upper = data_max if config.value_max is None else float(config.value_max)
|
|
57
|
+
if lower >= upper:
|
|
58
|
+
if config.value_min is not None and config.value_max is None:
|
|
59
|
+
upper = lower + 1.0
|
|
60
|
+
elif config.value_min is None and config.value_max is not None:
|
|
61
|
+
lower = upper - 1.0
|
|
62
|
+
elif lower == 0:
|
|
63
|
+
upper = 1.0
|
|
64
|
+
else:
|
|
65
|
+
padding = max(abs(lower) * 0.05, 0.5)
|
|
66
|
+
lower -= padding
|
|
67
|
+
upper += padding
|
|
68
|
+
return lower, upper
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plot_similarity_matrix(
|
|
72
|
+
result: SimilarityMatrixResult,
|
|
73
|
+
*,
|
|
74
|
+
config: HeatmapConfig | None = None,
|
|
75
|
+
) -> SVGArtifact:
|
|
76
|
+
"""Render an existing matrix in its original order without mirroring it."""
|
|
77
|
+
|
|
78
|
+
if not isinstance(result, SimilarityMatrixResult):
|
|
79
|
+
raise TypeError("result must be SimilarityMatrixResult.")
|
|
80
|
+
resolved = HeatmapConfig() if config is None else config
|
|
81
|
+
if not isinstance(resolved, HeatmapConfig):
|
|
82
|
+
raise TypeError("config must be HeatmapConfig or None.")
|
|
83
|
+
_validate_labels(result.labels)
|
|
84
|
+
|
|
85
|
+
size = result.item_count
|
|
86
|
+
display_limit = min(resolved.max_rows, resolved.max_columns)
|
|
87
|
+
if size > display_limit and resolved.limit_policy is LimitPolicy.ERROR:
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
"Similarity heatmap exceeds its configured dimensions.",
|
|
90
|
+
code="VISUALIZATION_SIZE_LIMIT",
|
|
91
|
+
context={
|
|
92
|
+
"item_count": size,
|
|
93
|
+
"max_columns": resolved.max_columns,
|
|
94
|
+
"max_rows": resolved.max_rows,
|
|
95
|
+
},
|
|
96
|
+
hint="Raise the limits or explicitly choose limit_policy='stride'.",
|
|
97
|
+
)
|
|
98
|
+
indices = _stride_indices(size, display_limit)
|
|
99
|
+
strided = len(indices) < size
|
|
100
|
+
lower, upper = _scale(result.values, resolved)
|
|
101
|
+
issues: list[Issue] = []
|
|
102
|
+
if size == 0:
|
|
103
|
+
issues.append(
|
|
104
|
+
Issue(
|
|
105
|
+
"VIZ_EMPTY_MATRIX",
|
|
106
|
+
IssueSeverity.INFO,
|
|
107
|
+
"The similarity result contains an empty matrix.",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
if strided:
|
|
111
|
+
issues.append(
|
|
112
|
+
Issue(
|
|
113
|
+
"VIZ_MATRIX_STRIDED",
|
|
114
|
+
IssueSeverity.WARNING,
|
|
115
|
+
"Rows and columns were explicitly display-sampled with the same stride.",
|
|
116
|
+
details={"input_items": size, "displayed_items": len(indices)},
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
clipped_count = sum(value < lower or value > upper for row in result.values for value in row)
|
|
120
|
+
if clipped_count:
|
|
121
|
+
issues.append(
|
|
122
|
+
Issue(
|
|
123
|
+
"VIZ_MATRIX_VALUES_CLIPPED",
|
|
124
|
+
IssueSeverity.WARNING,
|
|
125
|
+
"Values outside the configured color range were clipped.",
|
|
126
|
+
details={"count": clipped_count},
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
labels = tuple(result.labels[index] for index in indices)
|
|
131
|
+
title = resolved.title or f"{result.name} ({result.method})"
|
|
132
|
+
label_gutter = 170 if resolved.show_labels and labels else 24
|
|
133
|
+
top_gutter = 190 if resolved.show_labels and labels else 60
|
|
134
|
+
legend_width = 80
|
|
135
|
+
matrix_size = max(1, len(indices)) * resolved.cell_size
|
|
136
|
+
width = label_gutter + matrix_size + legend_width + 28
|
|
137
|
+
height = top_gutter + matrix_size + 42
|
|
138
|
+
metadata = {
|
|
139
|
+
"display_indices": indices,
|
|
140
|
+
"displayed_items": len(indices),
|
|
141
|
+
"input_items": size,
|
|
142
|
+
"matrix_method": result.method,
|
|
143
|
+
"strided": strided,
|
|
144
|
+
"symmetric": result.symmetric,
|
|
145
|
+
"value_kind": result.value_kind,
|
|
146
|
+
"value_max": upper,
|
|
147
|
+
"value_min": lower,
|
|
148
|
+
}
|
|
149
|
+
builder = SVGBuilder(
|
|
150
|
+
width,
|
|
151
|
+
height,
|
|
152
|
+
title=title,
|
|
153
|
+
description="Heatmap of a precomputed DNA similarity or distance matrix.",
|
|
154
|
+
kind="similarity-matrix",
|
|
155
|
+
theme=resolved.theme,
|
|
156
|
+
metadata=metadata,
|
|
157
|
+
)
|
|
158
|
+
builder.text(
|
|
159
|
+
width / 2,
|
|
160
|
+
28,
|
|
161
|
+
title,
|
|
162
|
+
fill=resolved.theme.foreground,
|
|
163
|
+
font_family=resolved.theme.font_family,
|
|
164
|
+
font_size=16,
|
|
165
|
+
font_weight="bold",
|
|
166
|
+
text_anchor="middle",
|
|
167
|
+
class_="plot-title",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not indices:
|
|
171
|
+
builder.text(
|
|
172
|
+
width / 2,
|
|
173
|
+
height / 2,
|
|
174
|
+
"Empty matrix",
|
|
175
|
+
fill=resolved.theme.muted,
|
|
176
|
+
font_family=resolved.theme.font_family,
|
|
177
|
+
font_size=14,
|
|
178
|
+
text_anchor="middle",
|
|
179
|
+
class_="empty-state",
|
|
180
|
+
)
|
|
181
|
+
return builder.artifact(issues=issues)
|
|
182
|
+
|
|
183
|
+
for display_row, source_row in enumerate(indices):
|
|
184
|
+
y = top_gutter + display_row * resolved.cell_size
|
|
185
|
+
if resolved.show_labels:
|
|
186
|
+
builder.text(
|
|
187
|
+
label_gutter - 8,
|
|
188
|
+
y + resolved.cell_size * 0.72,
|
|
189
|
+
_short_label(result.labels[source_row]),
|
|
190
|
+
fill=resolved.theme.foreground,
|
|
191
|
+
font_family=resolved.theme.font_family,
|
|
192
|
+
font_size=10,
|
|
193
|
+
text_anchor="end",
|
|
194
|
+
class_="row-label",
|
|
195
|
+
data_label=result.labels[source_row],
|
|
196
|
+
data_source_index=source_row,
|
|
197
|
+
)
|
|
198
|
+
for display_column, source_column in enumerate(indices):
|
|
199
|
+
value = result.values[source_row][source_column]
|
|
200
|
+
fraction = (min(upper, max(lower, value)) - lower) / (upper - lower)
|
|
201
|
+
cell_x = label_gutter + display_column * resolved.cell_size
|
|
202
|
+
builder.rect(
|
|
203
|
+
cell_x,
|
|
204
|
+
y,
|
|
205
|
+
resolved.cell_size,
|
|
206
|
+
resolved.cell_size,
|
|
207
|
+
fill=interpolate_color(resolved.low_color, resolved.high_color, fraction),
|
|
208
|
+
stroke=resolved.theme.background,
|
|
209
|
+
stroke_width=0.5,
|
|
210
|
+
class_="heatmap-cell",
|
|
211
|
+
data_column=source_column,
|
|
212
|
+
data_row=source_row,
|
|
213
|
+
data_value=number(value),
|
|
214
|
+
)
|
|
215
|
+
if resolved.show_values and resolved.cell_size >= 18:
|
|
216
|
+
builder.text(
|
|
217
|
+
cell_x + resolved.cell_size / 2,
|
|
218
|
+
y + resolved.cell_size * 0.7,
|
|
219
|
+
number(value),
|
|
220
|
+
fill=resolved.theme.foreground,
|
|
221
|
+
font_family=resolved.theme.font_family,
|
|
222
|
+
font_size=max(7, min(10, resolved.cell_size - 9)),
|
|
223
|
+
text_anchor="middle",
|
|
224
|
+
class_="cell-value",
|
|
225
|
+
)
|
|
226
|
+
if resolved.show_labels:
|
|
227
|
+
for display_column, source_column in enumerate(indices):
|
|
228
|
+
label_x = label_gutter + (display_column + 0.5) * resolved.cell_size
|
|
229
|
+
label_y = top_gutter - 8
|
|
230
|
+
builder.text(
|
|
231
|
+
label_x,
|
|
232
|
+
label_y,
|
|
233
|
+
_short_label(result.labels[source_column]),
|
|
234
|
+
fill=resolved.theme.foreground,
|
|
235
|
+
font_family=resolved.theme.font_family,
|
|
236
|
+
font_size=10,
|
|
237
|
+
text_anchor="start",
|
|
238
|
+
transform=f"rotate(-45 {number(label_x)} {number(label_y)})",
|
|
239
|
+
class_="column-label",
|
|
240
|
+
data_label=result.labels[source_column],
|
|
241
|
+
data_source_index=source_column,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
legend_x = label_gutter + matrix_size + 24
|
|
245
|
+
legend_steps = 20
|
|
246
|
+
legend_height = min(200, matrix_size)
|
|
247
|
+
for step in range(legend_steps):
|
|
248
|
+
fraction = step / (legend_steps - 1)
|
|
249
|
+
legend_y = top_gutter + (1 - fraction) * legend_height
|
|
250
|
+
builder.rect(
|
|
251
|
+
legend_x,
|
|
252
|
+
legend_y,
|
|
253
|
+
14,
|
|
254
|
+
legend_height / legend_steps + 1,
|
|
255
|
+
fill=interpolate_color(resolved.low_color, resolved.high_color, fraction),
|
|
256
|
+
class_="legend-step",
|
|
257
|
+
)
|
|
258
|
+
builder.text(
|
|
259
|
+
legend_x + 20,
|
|
260
|
+
top_gutter + 8,
|
|
261
|
+
number(upper),
|
|
262
|
+
fill=resolved.theme.muted,
|
|
263
|
+
font_family=resolved.theme.font_family,
|
|
264
|
+
font_size=9,
|
|
265
|
+
class_="legend-maximum",
|
|
266
|
+
)
|
|
267
|
+
builder.text(
|
|
268
|
+
legend_x + 20,
|
|
269
|
+
top_gutter + legend_height,
|
|
270
|
+
number(lower),
|
|
271
|
+
fill=resolved.theme.muted,
|
|
272
|
+
font_family=resolved.theme.font_family,
|
|
273
|
+
font_size=9,
|
|
274
|
+
class_="legend-minimum",
|
|
275
|
+
)
|
|
276
|
+
return builder.artifact(issues=issues)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
__all__ = ["plot_similarity_matrix"]
|