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,211 @@
|
|
|
1
|
+
"""Configurable transparent aggregation of evaluation and metric results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeAlias
|
|
9
|
+
|
|
10
|
+
from dnakit.core import MetricResult
|
|
11
|
+
from dnakit.exceptions import ConfigurationError
|
|
12
|
+
|
|
13
|
+
from ._shared import report
|
|
14
|
+
from .config import ScorecardConfig, ScoreRule
|
|
15
|
+
from .results import EvaluationReport
|
|
16
|
+
|
|
17
|
+
ScoreInput: TypeAlias = EvaluationReport | MetricResult[object] | int | float | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class _ResolvedScore:
|
|
22
|
+
name: str
|
|
23
|
+
raw_value: float | None
|
|
24
|
+
source: str
|
|
25
|
+
metric: str | None
|
|
26
|
+
source_method: str | None
|
|
27
|
+
source_algorithm_version: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _number(value: object, name: str) -> float:
|
|
31
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
"Scorecard values must be finite numbers.",
|
|
34
|
+
code="INVALID_SCORECARD_VALUE",
|
|
35
|
+
context={"component": name, "value": value},
|
|
36
|
+
)
|
|
37
|
+
return float(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve(name: str, value: object, rule: ScoreRule) -> _ResolvedScore:
|
|
41
|
+
if value is None:
|
|
42
|
+
return _ResolvedScore(name, None, "missing", rule.metric, None, None)
|
|
43
|
+
if isinstance(value, EvaluationReport):
|
|
44
|
+
metric_name = rule.metric or "score"
|
|
45
|
+
if metric_name not in value.metrics:
|
|
46
|
+
return _ResolvedScore(
|
|
47
|
+
name,
|
|
48
|
+
None,
|
|
49
|
+
f"EvaluationReport:{value.name}",
|
|
50
|
+
metric_name,
|
|
51
|
+
value.method,
|
|
52
|
+
value.algorithm_version,
|
|
53
|
+
)
|
|
54
|
+
return _ResolvedScore(
|
|
55
|
+
name,
|
|
56
|
+
_number(value.metrics[metric_name], name),
|
|
57
|
+
f"EvaluationReport:{value.name}",
|
|
58
|
+
metric_name,
|
|
59
|
+
value.method,
|
|
60
|
+
value.algorithm_version,
|
|
61
|
+
)
|
|
62
|
+
if isinstance(value, MetricResult):
|
|
63
|
+
if rule.metric is not None:
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
"ScoreRule.metric is only used to select EvaluationReport.metrics.",
|
|
66
|
+
code="INVALID_SCORECARD_RULE",
|
|
67
|
+
context={"component": name},
|
|
68
|
+
)
|
|
69
|
+
return _ResolvedScore(
|
|
70
|
+
name,
|
|
71
|
+
_number(value.value, name),
|
|
72
|
+
f"MetricResult:{value.name}",
|
|
73
|
+
None,
|
|
74
|
+
value.method,
|
|
75
|
+
value.algorithm_version,
|
|
76
|
+
)
|
|
77
|
+
return _ResolvedScore(name, _number(value, name), "numeric", None, None, None)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize(value: float, rule: ScoreRule) -> tuple[float, bool]:
|
|
81
|
+
unclipped = (value - rule.minimum) / (rule.maximum - rule.minimum)
|
|
82
|
+
if rule.direction == "lower_is_better":
|
|
83
|
+
unclipped = 1.0 - unclipped
|
|
84
|
+
return min(1.0, max(0.0, unclipped)), not 0.0 <= unclipped <= 1.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def evaluate_scorecard(
|
|
88
|
+
values: Mapping[str, object],
|
|
89
|
+
*,
|
|
90
|
+
config: ScorecardConfig,
|
|
91
|
+
) -> EvaluationReport:
|
|
92
|
+
"""Normalize and aggregate named inputs while retaining every contribution."""
|
|
93
|
+
|
|
94
|
+
if not isinstance(values, Mapping):
|
|
95
|
+
raise ConfigurationError("values must be a mapping.", code="INVALID_SCORECARD_INPUT")
|
|
96
|
+
if not isinstance(config, ScorecardConfig):
|
|
97
|
+
raise TypeError("config must be ScorecardConfig.")
|
|
98
|
+
unknown = set(values) - set(config.rules)
|
|
99
|
+
if unknown:
|
|
100
|
+
raise ConfigurationError(
|
|
101
|
+
"Scorecard values contain unknown components.",
|
|
102
|
+
code="UNKNOWN_SCORECARD_COMPONENT",
|
|
103
|
+
context={"components": sorted(unknown)},
|
|
104
|
+
)
|
|
105
|
+
resolved = tuple(_resolve(name, values.get(name), rule) for name, rule in config.rules.items())
|
|
106
|
+
missing = tuple(item.name for item in resolved if item.raw_value is None)
|
|
107
|
+
if missing and config.missing_policy == "error":
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
"Scorecard is missing required component values.",
|
|
110
|
+
code="MISSING_SCORECARD_COMPONENT",
|
|
111
|
+
context={"components": missing},
|
|
112
|
+
)
|
|
113
|
+
contributions: list[dict[str, object]] = []
|
|
114
|
+
weighted_sum = 0.0
|
|
115
|
+
weight_sum = 0.0
|
|
116
|
+
for item in resolved:
|
|
117
|
+
rule = config.rules[item.name]
|
|
118
|
+
if not isinstance(rule, ScoreRule):
|
|
119
|
+
raise AssertionError("ScorecardConfig accepted a non-ScoreRule value.")
|
|
120
|
+
if item.raw_value is None and config.missing_policy == "omit":
|
|
121
|
+
contributions.append(
|
|
122
|
+
{
|
|
123
|
+
"name": item.name,
|
|
124
|
+
"source": item.source,
|
|
125
|
+
"source_metric": item.metric,
|
|
126
|
+
"source_method": item.source_method,
|
|
127
|
+
"source_algorithm_version": item.source_algorithm_version,
|
|
128
|
+
"raw_value": None,
|
|
129
|
+
"normalized_value": None,
|
|
130
|
+
"weight": rule.weight,
|
|
131
|
+
"weighted_contribution": None,
|
|
132
|
+
"direction": rule.direction,
|
|
133
|
+
"minimum": rule.minimum,
|
|
134
|
+
"maximum": rule.maximum,
|
|
135
|
+
"status": "omitted",
|
|
136
|
+
"clipped": False,
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
raw_value = 0.0 if item.raw_value is None else item.raw_value
|
|
141
|
+
normalized, clipped = (
|
|
142
|
+
(0.0, False) if item.raw_value is None else _normalize(raw_value, rule)
|
|
143
|
+
)
|
|
144
|
+
contribution = normalized * rule.weight
|
|
145
|
+
weighted_sum += contribution
|
|
146
|
+
weight_sum += rule.weight
|
|
147
|
+
contributions.append(
|
|
148
|
+
{
|
|
149
|
+
"name": item.name,
|
|
150
|
+
"source": item.source,
|
|
151
|
+
"source_metric": item.metric,
|
|
152
|
+
"source_method": item.source_method,
|
|
153
|
+
"source_algorithm_version": item.source_algorithm_version,
|
|
154
|
+
"raw_value": item.raw_value,
|
|
155
|
+
"imputed_value": raw_value if item.raw_value is None else None,
|
|
156
|
+
"normalized_value": normalized,
|
|
157
|
+
"weight": rule.weight,
|
|
158
|
+
"weighted_contribution": contribution,
|
|
159
|
+
"direction": rule.direction,
|
|
160
|
+
"minimum": rule.minimum,
|
|
161
|
+
"maximum": rule.maximum,
|
|
162
|
+
"status": "zero-imputed" if item.raw_value is None else "included",
|
|
163
|
+
"clipped": clipped,
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
if weight_sum == 0:
|
|
167
|
+
raise ConfigurationError(
|
|
168
|
+
"No scorecard component remains after applying missing_policy.",
|
|
169
|
+
code="EMPTY_SCORECARD",
|
|
170
|
+
)
|
|
171
|
+
score = weighted_sum / weight_sum
|
|
172
|
+
status = (
|
|
173
|
+
"pass"
|
|
174
|
+
if score >= config.pass_score
|
|
175
|
+
else ("warning" if score >= config.warning_score else "fail")
|
|
176
|
+
)
|
|
177
|
+
return report(
|
|
178
|
+
name="scorecard",
|
|
179
|
+
method="weighted-clipped-min-max-normalization",
|
|
180
|
+
version="eval-scorecard-v1",
|
|
181
|
+
parameters={
|
|
182
|
+
"rules": {
|
|
183
|
+
name: {
|
|
184
|
+
"direction": rule.direction,
|
|
185
|
+
"weight": rule.weight,
|
|
186
|
+
"minimum": rule.minimum,
|
|
187
|
+
"maximum": rule.maximum,
|
|
188
|
+
"metric": rule.metric,
|
|
189
|
+
}
|
|
190
|
+
for name, rule in config.rules.items()
|
|
191
|
+
},
|
|
192
|
+
"formula": "sum(weight_i * clipped_normalized_i) / sum(included_weight_i)",
|
|
193
|
+
"higher_normalization": "clip((x-min)/(max-min),0,1)",
|
|
194
|
+
"lower_normalization": "clip(1-(x-min)/(max-min),0,1)",
|
|
195
|
+
"missing_policy": config.missing_policy,
|
|
196
|
+
"zero_missing_definition": "normalized contribution is zero before weighting",
|
|
197
|
+
"warning_score": config.warning_score,
|
|
198
|
+
"pass_score": config.pass_score,
|
|
199
|
+
},
|
|
200
|
+
metrics={
|
|
201
|
+
"score": score,
|
|
202
|
+
"status": status,
|
|
203
|
+
"weighted_sum": weighted_sum,
|
|
204
|
+
"included_weight_sum": weight_sum,
|
|
205
|
+
"missing_components": missing,
|
|
206
|
+
"contributions": tuple(contributions),
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = ["ScoreInput", "evaluate_scorecard"]
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Single-sequence validity, ambiguity, complexity, and quality evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import Counter
|
|
7
|
+
|
|
8
|
+
from dnakit.core import Gap, IssueSeverity
|
|
9
|
+
from dnakit.core._json import FrozenDict, to_json_compatible
|
|
10
|
+
from dnakit.descriptors import (
|
|
11
|
+
exact_repeat_fraction,
|
|
12
|
+
homopolymer_runs,
|
|
13
|
+
linguistic_complexity,
|
|
14
|
+
shannon_entropy,
|
|
15
|
+
)
|
|
16
|
+
from dnakit.standardize import ValidationConfig, validate
|
|
17
|
+
|
|
18
|
+
from ._shared import (
|
|
19
|
+
EvaluationInput,
|
|
20
|
+
InputItem,
|
|
21
|
+
aggregate_numeric,
|
|
22
|
+
as_float,
|
|
23
|
+
issue,
|
|
24
|
+
materialize_input,
|
|
25
|
+
report,
|
|
26
|
+
)
|
|
27
|
+
from .config import (
|
|
28
|
+
AmbiguityEvaluationConfig,
|
|
29
|
+
ComplexityEvaluationConfig,
|
|
30
|
+
EvaluationLimits,
|
|
31
|
+
QualityEvaluationConfig,
|
|
32
|
+
)
|
|
33
|
+
from .results import EvaluationEntry, EvaluationReport
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validation_entry(item: InputItem, index: int, config: ValidationConfig) -> EvaluationEntry:
|
|
37
|
+
result = validate(item.record or item.sequence, config=config)
|
|
38
|
+
return EvaluationEntry(
|
|
39
|
+
item.subject_id,
|
|
40
|
+
index,
|
|
41
|
+
FrozenDict(
|
|
42
|
+
{
|
|
43
|
+
"is_valid": result.is_valid,
|
|
44
|
+
"symbol_length": result.symbol_length,
|
|
45
|
+
"coordinate_span": result.coordinate_span,
|
|
46
|
+
"ambiguity_count": result.ambiguity.total_count,
|
|
47
|
+
"ambiguity_fraction": result.ambiguity.fraction,
|
|
48
|
+
"quality": to_json_compatible(result.quality),
|
|
49
|
+
}
|
|
50
|
+
),
|
|
51
|
+
result.issues,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def evaluate_validity(
|
|
56
|
+
value: EvaluationInput,
|
|
57
|
+
*,
|
|
58
|
+
config: ValidationConfig | None = None,
|
|
59
|
+
limits: EvaluationLimits | None = None,
|
|
60
|
+
) -> EvaluationReport:
|
|
61
|
+
"""Evaluate object-level alphabet, length, gap, and optional record constraints."""
|
|
62
|
+
|
|
63
|
+
resolved = ValidationConfig() if config is None else config
|
|
64
|
+
resolved_limits = EvaluationLimits() if limits is None else limits
|
|
65
|
+
if not isinstance(resolved, ValidationConfig):
|
|
66
|
+
raise TypeError("config must be ValidationConfig or None.")
|
|
67
|
+
if not isinstance(resolved_limits, EvaluationLimits):
|
|
68
|
+
raise TypeError("limits must be EvaluationLimits or None.")
|
|
69
|
+
items = materialize_input(value, limits=resolved_limits)
|
|
70
|
+
entries = tuple(_validation_entry(item, index, resolved) for index, item in enumerate(items))
|
|
71
|
+
valid_count = sum(entry.metrics["is_valid"] is True for entry in entries)
|
|
72
|
+
return report(
|
|
73
|
+
name="sequence_validity",
|
|
74
|
+
method="dnakit-standardize-validation",
|
|
75
|
+
version="eval-validity-v1",
|
|
76
|
+
parameters={"validation": resolved, "limits": resolved_limits},
|
|
77
|
+
metrics={
|
|
78
|
+
"record_count": len(entries),
|
|
79
|
+
"valid_count": valid_count,
|
|
80
|
+
"invalid_count": len(entries) - valid_count,
|
|
81
|
+
"valid_fraction": valid_count / len(entries) if entries else None,
|
|
82
|
+
},
|
|
83
|
+
entries=entries,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ambiguity_entry(
|
|
88
|
+
item: InputItem,
|
|
89
|
+
index: int,
|
|
90
|
+
config: AmbiguityEvaluationConfig,
|
|
91
|
+
) -> EvaluationEntry:
|
|
92
|
+
sequence = item.sequence
|
|
93
|
+
positions = tuple(
|
|
94
|
+
index for index, symbol in enumerate(sequence.symbols) if symbol not in "ACGT"
|
|
95
|
+
)
|
|
96
|
+
counts = Counter(sequence.symbols[position] for position in positions)
|
|
97
|
+
gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
|
|
98
|
+
if config.gap_denominator_policy == "error" and gaps:
|
|
99
|
+
from dnakit.exceptions import UnsupportedGapOperationError
|
|
100
|
+
|
|
101
|
+
raise UnsupportedGapOperationError(
|
|
102
|
+
"Ambiguity evaluation is configured to reject Gap objects.",
|
|
103
|
+
code="AMBIGUITY_GAP_REJECTED",
|
|
104
|
+
context={"subject_id": item.subject_id},
|
|
105
|
+
)
|
|
106
|
+
denominator: int | None = sequence.symbol_length
|
|
107
|
+
if config.gap_denominator_policy == "include_known":
|
|
108
|
+
denominator = sequence.coordinate_span
|
|
109
|
+
weighted_count = math.fsum(
|
|
110
|
+
counts[symbol] * float(config.symbol_weights[symbol]) for symbol in sorted(counts)
|
|
111
|
+
)
|
|
112
|
+
fraction = len(positions) / denominator if denominator else (0.0 if denominator == 0 else None)
|
|
113
|
+
weighted_fraction = (
|
|
114
|
+
weighted_count / denominator if denominator else (0.0 if denominator == 0 else None)
|
|
115
|
+
)
|
|
116
|
+
entry_issues = []
|
|
117
|
+
if denominator is None:
|
|
118
|
+
entry_issues.append(
|
|
119
|
+
issue(
|
|
120
|
+
"EVAL_AMBIGUITY_DENOMINATOR_UNKNOWN",
|
|
121
|
+
IssueSeverity.WARNING,
|
|
122
|
+
"Ambiguity fraction is undefined because a gap length is unknown.",
|
|
123
|
+
gap_denominator_policy=config.gap_denominator_policy,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
elif fraction is not None and fraction > config.max_fraction:
|
|
127
|
+
entry_issues.append(
|
|
128
|
+
issue(
|
|
129
|
+
"EVAL_AMBIGUITY_HIGH",
|
|
130
|
+
IssueSeverity.WARNING,
|
|
131
|
+
"Ambiguity fraction exceeds the configured threshold.",
|
|
132
|
+
fraction=fraction,
|
|
133
|
+
maximum=config.max_fraction,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return EvaluationEntry(
|
|
137
|
+
item.subject_id,
|
|
138
|
+
index,
|
|
139
|
+
FrozenDict(
|
|
140
|
+
{
|
|
141
|
+
"ambiguity_count": len(positions),
|
|
142
|
+
"ambiguity_fraction": fraction,
|
|
143
|
+
"weighted_ambiguity_count": weighted_count,
|
|
144
|
+
"weighted_ambiguity_fraction": weighted_fraction,
|
|
145
|
+
"denominator": denominator,
|
|
146
|
+
"positions": positions,
|
|
147
|
+
"counts_by_symbol": {symbol: counts[symbol] for symbol in sorted(counts)},
|
|
148
|
+
"gap_count": len(gaps),
|
|
149
|
+
"unknown_gap_count": sum(gap.length is None for gap in gaps),
|
|
150
|
+
"passes_threshold": fraction is not None and fraction <= config.max_fraction,
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
tuple(entry_issues),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def evaluate_ambiguity(
|
|
158
|
+
value: EvaluationInput,
|
|
159
|
+
*,
|
|
160
|
+
config: AmbiguityEvaluationConfig | None = None,
|
|
161
|
+
) -> EvaluationReport:
|
|
162
|
+
"""Report ambiguity symbols, positions, weighted impact, and explicit denominator."""
|
|
163
|
+
|
|
164
|
+
resolved = AmbiguityEvaluationConfig() if config is None else config
|
|
165
|
+
if not isinstance(resolved, AmbiguityEvaluationConfig):
|
|
166
|
+
raise TypeError("config must be AmbiguityEvaluationConfig or None.")
|
|
167
|
+
items = materialize_input(value, limits=resolved.limits)
|
|
168
|
+
entries = tuple(_ambiguity_entry(item, index, resolved) for index, item in enumerate(items))
|
|
169
|
+
return report(
|
|
170
|
+
name="ambiguity",
|
|
171
|
+
method="weighted-iupac-symbol-fraction",
|
|
172
|
+
version="eval-ambiguity-v1",
|
|
173
|
+
parameters={
|
|
174
|
+
"max_fraction": resolved.max_fraction,
|
|
175
|
+
"symbol_weights": resolved.symbol_weights,
|
|
176
|
+
"gap_denominator_policy": resolved.gap_denominator_policy,
|
|
177
|
+
"limits": resolved.limits,
|
|
178
|
+
"coordinate_system": "0-based-symbol",
|
|
179
|
+
},
|
|
180
|
+
metrics={
|
|
181
|
+
"record_count": len(entries),
|
|
182
|
+
"ambiguity_fraction_summary": aggregate_numeric(entries, "ambiguity_fraction"),
|
|
183
|
+
"weighted_ambiguity_fraction_summary": aggregate_numeric(
|
|
184
|
+
entries, "weighted_ambiguity_fraction"
|
|
185
|
+
),
|
|
186
|
+
"failing_count": sum(entry.metrics["passes_threshold"] is False for entry in entries),
|
|
187
|
+
},
|
|
188
|
+
entries=entries,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _complexity_entry(
|
|
193
|
+
item: InputItem,
|
|
194
|
+
index: int,
|
|
195
|
+
config: ComplexityEvaluationConfig,
|
|
196
|
+
) -> EvaluationEntry:
|
|
197
|
+
entropy = shannon_entropy(item.sequence, ambiguity_policy="ignore")
|
|
198
|
+
linguistic = linguistic_complexity(
|
|
199
|
+
item.sequence,
|
|
200
|
+
max_word_size=config.max_word_size,
|
|
201
|
+
ambiguity_policy="ignore",
|
|
202
|
+
max_observations=config.max_observations_per_sequence,
|
|
203
|
+
)
|
|
204
|
+
repeat = exact_repeat_fraction(
|
|
205
|
+
item.sequence,
|
|
206
|
+
max_unit_length=config.max_repeat_unit,
|
|
207
|
+
min_repeats=config.min_repeat_count,
|
|
208
|
+
ambiguity_policy="ignore",
|
|
209
|
+
max_comparisons=config.max_comparisons_per_sequence,
|
|
210
|
+
)
|
|
211
|
+
homopolymer = homopolymer_runs(item.sequence, ambiguity_policy="ignore")
|
|
212
|
+
entropy_normalized = min(1.0, max(0.0, entropy.entropy / 2.0))
|
|
213
|
+
homopolymer_cleanliness = (
|
|
214
|
+
1.0
|
|
215
|
+
if homopolymer.longest_length <= config.acceptable_homopolymer_length
|
|
216
|
+
else config.acceptable_homopolymer_length / homopolymer.longest_length
|
|
217
|
+
)
|
|
218
|
+
components = {
|
|
219
|
+
"entropy": entropy_normalized,
|
|
220
|
+
"linguistic": linguistic.score,
|
|
221
|
+
"repeat_cleanliness": 1.0 - repeat.repeat_fraction,
|
|
222
|
+
"homopolymer_cleanliness": homopolymer_cleanliness,
|
|
223
|
+
}
|
|
224
|
+
denominator = math.fsum(float(config.weights[name]) for name in components)
|
|
225
|
+
score = (
|
|
226
|
+
math.fsum(components[name] * float(config.weights[name]) for name in components)
|
|
227
|
+
/ denominator
|
|
228
|
+
)
|
|
229
|
+
return EvaluationEntry(
|
|
230
|
+
item.subject_id,
|
|
231
|
+
index,
|
|
232
|
+
FrozenDict(
|
|
233
|
+
{
|
|
234
|
+
"score": score,
|
|
235
|
+
"components": components,
|
|
236
|
+
"shannon_entropy_bits": entropy.entropy,
|
|
237
|
+
"linguistic_complexity": linguistic.score,
|
|
238
|
+
"repeat_fraction": repeat.repeat_fraction,
|
|
239
|
+
"longest_homopolymer": homopolymer.longest_length,
|
|
240
|
+
"ignored_ambiguity_count": item.sequence.ambiguity_count,
|
|
241
|
+
"repeat_comparisons": repeat.comparisons,
|
|
242
|
+
"complexity_observations": linguistic.observation_count,
|
|
243
|
+
}
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def evaluate_complexity(
|
|
249
|
+
value: EvaluationInput,
|
|
250
|
+
*,
|
|
251
|
+
config: ComplexityEvaluationConfig | None = None,
|
|
252
|
+
) -> EvaluationReport:
|
|
253
|
+
"""Combine normalized entropy, vocabulary, repeat, and homopolymer components."""
|
|
254
|
+
|
|
255
|
+
resolved = ComplexityEvaluationConfig() if config is None else config
|
|
256
|
+
if not isinstance(resolved, ComplexityEvaluationConfig):
|
|
257
|
+
raise TypeError("config must be ComplexityEvaluationConfig or None.")
|
|
258
|
+
items = materialize_input(value, limits=resolved.limits)
|
|
259
|
+
entries = tuple(_complexity_entry(item, index, resolved) for index, item in enumerate(items))
|
|
260
|
+
return report(
|
|
261
|
+
name="complexity",
|
|
262
|
+
method="weighted-normalized-transparent-components",
|
|
263
|
+
version="eval-complexity-v1",
|
|
264
|
+
parameters={
|
|
265
|
+
"weights": resolved.weights,
|
|
266
|
+
"entropy_normalization": "min(1,max(0,H_base2/2))",
|
|
267
|
+
"repeat_cleanliness": "1-exact_tandem_repeat_union_fraction",
|
|
268
|
+
"homopolymer_cleanliness": "1 if longest<=acceptable else acceptable/longest",
|
|
269
|
+
"max_word_size": resolved.max_word_size,
|
|
270
|
+
"max_repeat_unit": resolved.max_repeat_unit,
|
|
271
|
+
"min_repeat_count": resolved.min_repeat_count,
|
|
272
|
+
"acceptable_homopolymer_length": resolved.acceptable_homopolymer_length,
|
|
273
|
+
"max_observations_per_sequence": resolved.max_observations_per_sequence,
|
|
274
|
+
"max_comparisons_per_sequence": resolved.max_comparisons_per_sequence,
|
|
275
|
+
"limits": resolved.limits,
|
|
276
|
+
},
|
|
277
|
+
metrics={
|
|
278
|
+
"record_count": len(entries),
|
|
279
|
+
"score_summary": aggregate_numeric(entries, "score"),
|
|
280
|
+
"score": (
|
|
281
|
+
math.fsum(as_float(entry.metrics, "score") for entry in entries) / len(entries)
|
|
282
|
+
if entries
|
|
283
|
+
else None
|
|
284
|
+
),
|
|
285
|
+
},
|
|
286
|
+
entries=entries,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def evaluate_quality(
|
|
291
|
+
value: EvaluationInput,
|
|
292
|
+
*,
|
|
293
|
+
config: QualityEvaluationConfig | None = None,
|
|
294
|
+
) -> EvaluationReport:
|
|
295
|
+
"""Combine validity, ambiguity, complexity, length, and record completeness."""
|
|
296
|
+
|
|
297
|
+
resolved = QualityEvaluationConfig() if config is None else config
|
|
298
|
+
if not isinstance(resolved, QualityEvaluationConfig):
|
|
299
|
+
raise TypeError("config must be QualityEvaluationConfig or None.")
|
|
300
|
+
items = materialize_input(value, limits=resolved.complexity.limits)
|
|
301
|
+
entries: list[EvaluationEntry] = []
|
|
302
|
+
validation_config = ValidationConfig(
|
|
303
|
+
allow_empty=resolved.min_length == 0,
|
|
304
|
+
min_length=resolved.min_length,
|
|
305
|
+
max_length=resolved.max_length,
|
|
306
|
+
max_ambiguity_fraction=None,
|
|
307
|
+
)
|
|
308
|
+
for index, item in enumerate(items):
|
|
309
|
+
validity = _validation_entry(item, index, validation_config)
|
|
310
|
+
ambiguity = _ambiguity_entry(item, index, resolved.ambiguity)
|
|
311
|
+
complexity = _complexity_entry(item, index, resolved.complexity)
|
|
312
|
+
length = item.sequence.symbol_length
|
|
313
|
+
length_ok = length >= resolved.min_length and (
|
|
314
|
+
resolved.max_length is None or length <= resolved.max_length
|
|
315
|
+
)
|
|
316
|
+
completeness = 1.0
|
|
317
|
+
if item.record is not None and "phred_quality" in item.record.letter_annotations:
|
|
318
|
+
quality_values = item.record.letter_annotations["phred_quality"]
|
|
319
|
+
completeness = (
|
|
320
|
+
sum(value >= 20 for value in quality_values) / len(quality_values)
|
|
321
|
+
if quality_values
|
|
322
|
+
else 1.0
|
|
323
|
+
)
|
|
324
|
+
ambiguity_value = ambiguity.metrics["weighted_ambiguity_fraction"]
|
|
325
|
+
ambiguity_score = (
|
|
326
|
+
0.0
|
|
327
|
+
if ambiguity_value is None
|
|
328
|
+
else 1.0 - min(1.0, as_float(ambiguity.metrics, "weighted_ambiguity_fraction"))
|
|
329
|
+
)
|
|
330
|
+
components = {
|
|
331
|
+
"validity": float(validity.metrics["is_valid"] is True),
|
|
332
|
+
"ambiguity": ambiguity_score,
|
|
333
|
+
"complexity": as_float(complexity.metrics, "score"),
|
|
334
|
+
"length": float(length_ok),
|
|
335
|
+
"completeness": completeness,
|
|
336
|
+
}
|
|
337
|
+
weight_sum = math.fsum(float(resolved.weights[name]) for name in components)
|
|
338
|
+
score = (
|
|
339
|
+
math.fsum(value * float(resolved.weights[name]) for name, value in components.items())
|
|
340
|
+
/ weight_sum
|
|
341
|
+
)
|
|
342
|
+
status = (
|
|
343
|
+
"pass"
|
|
344
|
+
if score >= resolved.pass_score
|
|
345
|
+
else ("warning" if score >= resolved.warning_score else "fail")
|
|
346
|
+
)
|
|
347
|
+
entries.append(
|
|
348
|
+
EvaluationEntry(
|
|
349
|
+
item.subject_id,
|
|
350
|
+
index,
|
|
351
|
+
FrozenDict(
|
|
352
|
+
{
|
|
353
|
+
"score": score,
|
|
354
|
+
"status": status,
|
|
355
|
+
"components": components,
|
|
356
|
+
"symbol_length": length,
|
|
357
|
+
"phred_q20_fraction": completeness if item.record is not None else None,
|
|
358
|
+
}
|
|
359
|
+
),
|
|
360
|
+
validity.issues + ambiguity.issues,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
materialized = tuple(entries)
|
|
364
|
+
return report(
|
|
365
|
+
name="sequence_quality",
|
|
366
|
+
method="weighted-validity-ambiguity-complexity-length-completeness",
|
|
367
|
+
version="eval-quality-v1",
|
|
368
|
+
parameters={
|
|
369
|
+
"weights": resolved.weights,
|
|
370
|
+
"min_length": resolved.min_length,
|
|
371
|
+
"max_length": resolved.max_length,
|
|
372
|
+
"warning_score": resolved.warning_score,
|
|
373
|
+
"pass_score": resolved.pass_score,
|
|
374
|
+
"phred_completeness_rule": "fraction of provided phred_quality values >=20",
|
|
375
|
+
"missing_phred_policy": "neutral completeness=1",
|
|
376
|
+
"undefined_ambiguity_policy": "ambiguity component is zero",
|
|
377
|
+
"length_definition": "symbol_length excluding Gap spans",
|
|
378
|
+
"ambiguity_config": resolved.ambiguity,
|
|
379
|
+
"complexity_config": resolved.complexity,
|
|
380
|
+
},
|
|
381
|
+
metrics={
|
|
382
|
+
"record_count": len(materialized),
|
|
383
|
+
"score_summary": aggregate_numeric(materialized, "score"),
|
|
384
|
+
"score": (
|
|
385
|
+
math.fsum(as_float(entry.metrics, "score") for entry in materialized)
|
|
386
|
+
/ len(materialized)
|
|
387
|
+
if materialized
|
|
388
|
+
else None
|
|
389
|
+
),
|
|
390
|
+
"pass_count": sum(entry.metrics["status"] == "pass" for entry in materialized),
|
|
391
|
+
"warning_count": sum(entry.metrics["status"] == "warning" for entry in materialized),
|
|
392
|
+
"fail_count": sum(entry.metrics["status"] == "fail" for entry in materialized),
|
|
393
|
+
},
|
|
394
|
+
entries=materialized,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
__all__ = [
|
|
399
|
+
"evaluate_ambiguity",
|
|
400
|
+
"evaluate_complexity",
|
|
401
|
+
"evaluate_quality",
|
|
402
|
+
"evaluate_validity",
|
|
403
|
+
]
|