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,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"]