dynamic-systems-testing-framework 1.0.1__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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""MetricTree: composition of metric results with Boolean / weighted combinators.
|
|
2
|
+
|
|
3
|
+
See docs/vision.md "Metric composition" and docs/extensibility.md §6.
|
|
4
|
+
|
|
5
|
+
A spec with no ``metrics`` tree collapses to a degenerate flat-AND over
|
|
6
|
+
per-variable ``VariableComparison`` results (``implicit_and_tree``), which is
|
|
7
|
+
exactly the implicit semantics. An explicit ``metrics`` tree in
|
|
8
|
+
``test_spec.json`` reaches the and / or / k-of-n / warn / weighted
|
|
9
|
+
combinators below.
|
|
10
|
+
|
|
11
|
+
Design contract:
|
|
12
|
+
|
|
13
|
+
* Combinators are pure functions of child ``MetricResult``s. No I/O, no mutation.
|
|
14
|
+
* A combinator's output ``diagnostics`` must preserve enough of its children's
|
|
15
|
+
diagnostics that a report can show which branch failed and why.
|
|
16
|
+
* Leaves wrap a ``VariableComparison``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, cast
|
|
24
|
+
|
|
25
|
+
from .comparator import VariableComparison
|
|
26
|
+
from .sides import as_written
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Uniform metric result (combinator output shape)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class MetricResult:
|
|
35
|
+
"""Uniform shape produced by both leaves and combinators.
|
|
36
|
+
|
|
37
|
+
``VariableComparison`` is a richer, reporting-friendly leaf result; this
|
|
38
|
+
is the narrow type combinators pass around. A leaf is adapted to a
|
|
39
|
+
``MetricResult`` on the way in via :func:`leaf_from_variable`.
|
|
40
|
+
|
|
41
|
+
Fields:
|
|
42
|
+
passed — overall pass/fail after combinator logic.
|
|
43
|
+
score — numeric score; interpretation is combinator-dependent
|
|
44
|
+
(min NRMSE for AND, max NRMSE for OR, etc.). Not normalized
|
|
45
|
+
in general. A value of ``None`` means "no meaningful score"
|
|
46
|
+
(e.g. after a ``warn`` wrapper).
|
|
47
|
+
label — short human-readable tag for reports (e.g. a variable name
|
|
48
|
+
or a combinator name + children count).
|
|
49
|
+
diagnostics — open-ended structured extras. Reports render these.
|
|
50
|
+
children — empty for leaves; populated for combinator nodes so the
|
|
51
|
+
tree shape is reconstructible.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
passed: bool
|
|
55
|
+
score: float | None
|
|
56
|
+
label: str = ""
|
|
57
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
children: list[MetricResult] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Combinator protocol
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Combinator(ABC):
|
|
67
|
+
"""Strategy interface for combining child metric results into one."""
|
|
68
|
+
|
|
69
|
+
#: Short identifier used in config / diagnostics.
|
|
70
|
+
name: str = ""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def combine(self, children: list[MetricResult]) -> MetricResult: ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AndCombinator(Combinator):
|
|
77
|
+
"""All children must pass. Score is ``min`` over the children's scores.
|
|
78
|
+
|
|
79
|
+
Matches the implicit behavior of a spec with no ``metrics`` tree: a test
|
|
80
|
+
passes iff every variable's metric passes.
|
|
81
|
+
|
|
82
|
+
``min`` is only "the worst child" for lower-is-better metrics. Children
|
|
83
|
+
can mix directions (the registry's ``score_direction`` is ``"lower"``
|
|
84
|
+
for NRMSE-like scores, ``"higher"`` for tube coverage), and this node
|
|
85
|
+
does not consult it — ``passed`` is a pure Boolean AND and is always
|
|
86
|
+
right; the reported score is a summary number, not a ranking.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
name = "and"
|
|
90
|
+
|
|
91
|
+
def combine(self, children: list[MetricResult]) -> MetricResult:
|
|
92
|
+
if not children:
|
|
93
|
+
# Vacuously true. Score undefined; pick None rather than lie.
|
|
94
|
+
return MetricResult(passed=True, score=None, label="and[0]", children=[])
|
|
95
|
+
passed = all(c.passed for c in children)
|
|
96
|
+
scores = [c.score for c in children if c.score is not None]
|
|
97
|
+
score = min(scores) if scores else None
|
|
98
|
+
return MetricResult(
|
|
99
|
+
passed=passed,
|
|
100
|
+
score=score,
|
|
101
|
+
label=f"and[{len(children)}]",
|
|
102
|
+
diagnostics={"n_failed": sum(1 for c in children if not c.passed)},
|
|
103
|
+
children=list(children),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class OrCombinator(Combinator):
|
|
108
|
+
"""At least one child must pass. Score is ``max`` over the children's scores.
|
|
109
|
+
|
|
110
|
+
Same caveat as :class:`AndCombinator`: ``max`` is only "the best child"
|
|
111
|
+
for higher-is-better metrics, and this node does not consult the
|
|
112
|
+
registry's ``score_direction``. ``passed`` (a Boolean OR) is unaffected.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
name = "or"
|
|
116
|
+
|
|
117
|
+
def combine(self, children: list[MetricResult]) -> MetricResult:
|
|
118
|
+
if not children:
|
|
119
|
+
# Vacuously false for OR (no children = no passing child).
|
|
120
|
+
return MetricResult(passed=False, score=None, label="or[0]", children=[])
|
|
121
|
+
passed = any(c.passed for c in children)
|
|
122
|
+
scores = [c.score for c in children if c.score is not None]
|
|
123
|
+
score = max(scores) if scores else None
|
|
124
|
+
return MetricResult(
|
|
125
|
+
passed=passed,
|
|
126
|
+
score=score,
|
|
127
|
+
label=f"or[{len(children)}]",
|
|
128
|
+
diagnostics={"n_passed": sum(1 for c in children if c.passed)},
|
|
129
|
+
children=list(children),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class KOfNCombinator(Combinator):
|
|
134
|
+
"""At least K of N children must pass."""
|
|
135
|
+
|
|
136
|
+
name = "k-of-n"
|
|
137
|
+
|
|
138
|
+
def __init__(self, k: int):
|
|
139
|
+
if k < 0:
|
|
140
|
+
raise ValueError("k must be non-negative")
|
|
141
|
+
self.k = k
|
|
142
|
+
|
|
143
|
+
def combine(self, children: list[MetricResult]) -> MetricResult:
|
|
144
|
+
n_passed = sum(1 for c in children if c.passed)
|
|
145
|
+
passed = n_passed >= self.k
|
|
146
|
+
scores = [c.score for c in children if c.score is not None]
|
|
147
|
+
# Heuristic: score = K-th best child score (or worst if K > len).
|
|
148
|
+
score: float | None
|
|
149
|
+
if not scores:
|
|
150
|
+
score = None
|
|
151
|
+
else:
|
|
152
|
+
scores_sorted = sorted(scores, reverse=True)
|
|
153
|
+
idx = min(self.k - 1, len(scores_sorted) - 1) if self.k > 0 else 0
|
|
154
|
+
score = scores_sorted[idx] if scores_sorted else None
|
|
155
|
+
return MetricResult(
|
|
156
|
+
passed=passed,
|
|
157
|
+
score=score,
|
|
158
|
+
label=f"k-of-n[{self.k}/{len(children)}]",
|
|
159
|
+
diagnostics={"k": self.k, "n": len(children), "n_passed": n_passed},
|
|
160
|
+
children=list(children),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class WeightedCombinator(Combinator):
|
|
165
|
+
"""Weighted sum of child scores against a threshold (4.E).
|
|
166
|
+
|
|
167
|
+
Pass condition is direction-aware:
|
|
168
|
+
- ``direction="less"`` (default — NRMSE-like; lower is better):
|
|
169
|
+
``sum(w_i * score_i) < threshold``.
|
|
170
|
+
- ``direction="greater"`` (tube-like; higher is better):
|
|
171
|
+
``sum(w_i * score_i) > threshold``.
|
|
172
|
+
|
|
173
|
+
All children must produce a numeric score; if any child has ``score=None``,
|
|
174
|
+
the weighted node fails (with a diagnostic explaining why) since the
|
|
175
|
+
aggregate is undefined.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
name = "weighted"
|
|
179
|
+
|
|
180
|
+
def __init__(self, weights: list[float], threshold: float, direction: str = "less"):
|
|
181
|
+
if direction not in ("less", "greater"):
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"weighted: direction must be 'less' or 'greater', got {direction!r}"
|
|
184
|
+
)
|
|
185
|
+
if not weights:
|
|
186
|
+
raise ValueError("weighted: requires at least one weight")
|
|
187
|
+
self.weights = list(weights)
|
|
188
|
+
self.threshold = float(threshold)
|
|
189
|
+
self.direction = direction
|
|
190
|
+
|
|
191
|
+
def combine(self, children: list[MetricResult]) -> MetricResult:
|
|
192
|
+
if len(children) != len(self.weights):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"weighted: weights ({len(self.weights)}) must match "
|
|
195
|
+
f"children ({len(children)})"
|
|
196
|
+
)
|
|
197
|
+
if any(c.score is None for c in children):
|
|
198
|
+
return MetricResult(
|
|
199
|
+
passed=False,
|
|
200
|
+
score=None,
|
|
201
|
+
label=f"weighted[{len(children)}]",
|
|
202
|
+
diagnostics={
|
|
203
|
+
"reason": "child has no numeric score; weighted sum undefined",
|
|
204
|
+
"weights": list(self.weights),
|
|
205
|
+
"threshold": self.threshold,
|
|
206
|
+
"direction": self.direction,
|
|
207
|
+
},
|
|
208
|
+
children=list(children),
|
|
209
|
+
)
|
|
210
|
+
# Every child has a numeric score — the guard above returned otherwise.
|
|
211
|
+
weighted_sum = sum(
|
|
212
|
+
w * cast(float, c.score) for w, c in zip(self.weights, children)
|
|
213
|
+
)
|
|
214
|
+
passed = (
|
|
215
|
+
weighted_sum < self.threshold
|
|
216
|
+
if self.direction == "less"
|
|
217
|
+
else weighted_sum > self.threshold
|
|
218
|
+
)
|
|
219
|
+
return MetricResult(
|
|
220
|
+
passed=passed,
|
|
221
|
+
score=weighted_sum,
|
|
222
|
+
label=f"weighted[{len(children)}]",
|
|
223
|
+
diagnostics={
|
|
224
|
+
"weighted_sum": weighted_sum,
|
|
225
|
+
"threshold": self.threshold,
|
|
226
|
+
"direction": self.direction,
|
|
227
|
+
"weights": list(self.weights),
|
|
228
|
+
},
|
|
229
|
+
children=list(children),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class WarnCombinator(Combinator):
|
|
234
|
+
"""Single-child wrapper that always passes the parent but surfaces the
|
|
235
|
+
child's diagnostics as warnings.
|
|
236
|
+
|
|
237
|
+
Used when an additional baseline comparison is informational (e.g. an
|
|
238
|
+
experimental-data overlay) and should not gate pass/fail. A ``warn`` node
|
|
239
|
+
always reports ``passed=True``; the report renders the child's failure
|
|
240
|
+
(if any) as a warning rather than a failure.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
name = "warn"
|
|
244
|
+
|
|
245
|
+
def combine(self, children: list[MetricResult]) -> MetricResult:
|
|
246
|
+
if len(children) != 1:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"warn combinator expects exactly 1 child, got {len(children)}"
|
|
249
|
+
)
|
|
250
|
+
child = children[0]
|
|
251
|
+
# Surface child's failure (if any) as a warning in diagnostics; parent
|
|
252
|
+
# passes regardless. Score is intentionally None — a warn branch does
|
|
253
|
+
# not contribute to a meaningful aggregate score.
|
|
254
|
+
warned = not child.passed
|
|
255
|
+
return MetricResult(
|
|
256
|
+
passed=True,
|
|
257
|
+
score=None,
|
|
258
|
+
label="warn",
|
|
259
|
+
diagnostics={"warned": warned, "child_label": child.label},
|
|
260
|
+
children=[child],
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Leaf adapter + implicit-tree construction
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def leaf_from_variable(vc: VariableComparison) -> MetricResult:
|
|
270
|
+
"""Adapt a per-variable ``VariableComparison`` to a leaf ``MetricResult``.
|
|
271
|
+
|
|
272
|
+
The numeric ``score`` is chosen per mode:
|
|
273
|
+
* NRMSE — ``vc.nrmse`` (lower is better; worst = min)
|
|
274
|
+
* tube — ``vc.tube_points_inside`` (higher is better; uses OR if
|
|
275
|
+
composed with another tube node)
|
|
276
|
+
* final — ``vc.nrmse`` (same semantics)
|
|
277
|
+
|
|
278
|
+
Callers that need the raw ``VariableComparison`` can recover it from
|
|
279
|
+
``result.diagnostics['variable']``.
|
|
280
|
+
"""
|
|
281
|
+
if vc.mode == "tube" and vc.tube_points_inside is not None:
|
|
282
|
+
score = vc.tube_points_inside
|
|
283
|
+
else:
|
|
284
|
+
score = vc.nrmse
|
|
285
|
+
diagnostics = {
|
|
286
|
+
"mode": vc.mode,
|
|
287
|
+
"tolerance": as_written(vc.tolerance_used),
|
|
288
|
+
"max_abs_error": vc.max_abs_error,
|
|
289
|
+
"variable": vc,
|
|
290
|
+
}
|
|
291
|
+
if vc.against is not None:
|
|
292
|
+
# The baseline the criterion read; none where it reads none.
|
|
293
|
+
diagnostics["against"] = vc.against
|
|
294
|
+
return MetricResult(
|
|
295
|
+
passed=vc.passed,
|
|
296
|
+
score=score,
|
|
297
|
+
label=vc.name,
|
|
298
|
+
diagnostics=diagnostics,
|
|
299
|
+
children=[],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def stamp_leaf_path(leaf: MetricResult, path: str) -> None:
|
|
304
|
+
"""Address a leaf result by its RFC 6902 path — the same path a patch op uses.
|
|
305
|
+
|
|
306
|
+
Prefixes the stashed ``VariableComparison``'s geometry ids with the path
|
|
307
|
+
so a figure handle and a spec edit share one name. Idempotent: a mode
|
|
308
|
+
emits local ids, and a stamped id starts with ``/``.
|
|
309
|
+
"""
|
|
310
|
+
leaf.diagnostics["leaf_path"] = path
|
|
311
|
+
vc = leaf.diagnostics.get("variable")
|
|
312
|
+
if vc is not None:
|
|
313
|
+
vc.geometry = tuple(
|
|
314
|
+
p if p.id.startswith("/") else p.prefixed(path) for p in vc.geometry
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def implicit_and_tree(variables: list[VariableComparison]) -> MetricResult:
|
|
319
|
+
"""Build the implicit flat-AND tree from a list of per-variable comparisons.
|
|
320
|
+
|
|
321
|
+
This is the degenerate MetricTree that matches the current pass/fail
|
|
322
|
+
semantics exactly: a test passes iff every variable passes. Leaves are
|
|
323
|
+
stamped ``/metrics/children/<i>`` — the paths
|
|
324
|
+
:func:`~.tree_spec.synthesize_implicit_tree` produces for the same list,
|
|
325
|
+
so the report can look leaves up by path on both evaluation routes.
|
|
326
|
+
"""
|
|
327
|
+
leaves = []
|
|
328
|
+
for i, vc in enumerate(variables):
|
|
329
|
+
leaf = leaf_from_variable(vc)
|
|
330
|
+
stamp_leaf_path(leaf, f"/metrics/children/{i}")
|
|
331
|
+
leaves.append(leaf)
|
|
332
|
+
return AndCombinator().combine(leaves)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""UI schema derived from a mode's Config dataclass — what ``export-schema``
|
|
2
|
+
writes as JSON-Schema and the authoring tool's form emitter renders.
|
|
3
|
+
|
|
4
|
+
Each :class:`ComparisonMode`'s Config is a frozen dataclass with strictly
|
|
5
|
+
typed fields. :func:`derive_schema` reads those types via
|
|
6
|
+
:func:`typing.get_type_hints` and returns the introspected field list —
|
|
7
|
+
types, defaults, ``Literal`` choices and the display metadata — as a data
|
|
8
|
+
artifact. :mod:`dstf.reporting.schema_export` turns it into the published
|
|
9
|
+
JSON Schema; the authoring tool builds its forms from the same
|
|
10
|
+
introspection. The report has no forms and does not use this module.
|
|
11
|
+
|
|
12
|
+
The :class:`ComparisonMode` ABC stays pure compute — no UI coupling on the
|
|
13
|
+
mode class. The bridge lives here instead, keyed on mode name.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import types
|
|
20
|
+
import typing
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Union
|
|
24
|
+
|
|
25
|
+
from .sides import Sides, as_written
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Schema types
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
#: Recognized field types. ``sides`` is a tolerance with two sides
|
|
32
|
+
#: (:class:`~.sides.Sides`): a number, or a ``{"lower", "upper"}`` pair.
|
|
33
|
+
#: ``passthrough`` is the fallback for complex types (nested dataclasses,
|
|
34
|
+
#: list[dict], etc.) that introspection cannot reduce to a scalar or an
|
|
35
|
+
#: enum — a consumer renders those as raw JSON.
|
|
36
|
+
FIELD_TYPES = {"float", "int", "bool", "str", "enum", "sides", "passthrough"}
|
|
37
|
+
|
|
38
|
+
#: What the two sides of a ``sides`` field are called, lower first, unless
|
|
39
|
+
#: its metadata says (``side_labels``): a value's are below and above its
|
|
40
|
+
#: target. A time tolerance names its own — ``early`` / ``late``.
|
|
41
|
+
SIDE_LABELS = ("−", "+")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FieldSpec:
|
|
46
|
+
name: str
|
|
47
|
+
type: str # one of FIELD_TYPES
|
|
48
|
+
default: Any = None
|
|
49
|
+
optional: bool = False
|
|
50
|
+
choices: list[str] | None = None
|
|
51
|
+
label: str | None = None
|
|
52
|
+
help: str | None = None
|
|
53
|
+
# UI hints for number inputs. ``ui_max`` is a SOFT cap: a consumer that
|
|
54
|
+
# renders an input raises it to fit a larger current value rather than
|
|
55
|
+
# clamping the value down.
|
|
56
|
+
ui_min: float | None = None
|
|
57
|
+
ui_max: float | None = None
|
|
58
|
+
# A ``sides`` field's two labels, lower first; None on every other kind.
|
|
59
|
+
side_labels: tuple[str, str] | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Schema:
|
|
64
|
+
mode: str | None = None
|
|
65
|
+
fields: list[FieldSpec] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return {
|
|
69
|
+
"mode": self.mode,
|
|
70
|
+
"fields": [dataclasses.asdict(f) for f in self.fields],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Introspection
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def derive_schema(config_cls: type, *, mode: str | None = None) -> Schema:
|
|
80
|
+
"""Inspect a dataclass and return its UI schema.
|
|
81
|
+
|
|
82
|
+
Handles ``float``, ``int``, ``bool``, ``str``, ``Optional[X]``,
|
|
83
|
+
``Literal[...]``, and ``Optional[Literal[...]]``. Unknown shapes
|
|
84
|
+
(``list[dict]``, nested dataclasses, etc.) degrade to
|
|
85
|
+
``type="passthrough"`` so callers can render a raw-JSON fallback
|
|
86
|
+
without crashing.
|
|
87
|
+
|
|
88
|
+
Reads ``metadata`` from :func:`dataclasses.field` for display hints:
|
|
89
|
+
- ``label``: human-readable label (defaults to a titleized name)
|
|
90
|
+
- ``help``: tooltip text
|
|
91
|
+
- ``side_labels``: what a ``sides`` field's two sides are called,
|
|
92
|
+
lower first (defaults to :data:`SIDE_LABELS`)
|
|
93
|
+
"""
|
|
94
|
+
if not dataclasses.is_dataclass(config_cls):
|
|
95
|
+
raise TypeError(f"{config_cls!r} is not a dataclass")
|
|
96
|
+
|
|
97
|
+
hints = typing.get_type_hints(config_cls)
|
|
98
|
+
fields_out: list[FieldSpec] = []
|
|
99
|
+
for f in dataclasses.fields(config_cls):
|
|
100
|
+
hint = hints.get(f.name, f.type)
|
|
101
|
+
kind, optional, choices = _classify_type(hint)
|
|
102
|
+
default = _field_default(f)
|
|
103
|
+
meta: Mapping[str, Any] = f.metadata or {}
|
|
104
|
+
fields_out.append(
|
|
105
|
+
FieldSpec(
|
|
106
|
+
name=f.name,
|
|
107
|
+
type=kind,
|
|
108
|
+
default=default,
|
|
109
|
+
optional=optional,
|
|
110
|
+
choices=choices,
|
|
111
|
+
label=meta.get("label") or _titleize(f.name),
|
|
112
|
+
help=meta.get("help"),
|
|
113
|
+
ui_min=meta.get("ui_min"),
|
|
114
|
+
ui_max=meta.get("ui_max"),
|
|
115
|
+
side_labels=(
|
|
116
|
+
tuple(meta.get("side_labels") or SIDE_LABELS)
|
|
117
|
+
if kind == "sides"
|
|
118
|
+
else None
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return Schema(mode=mode, fields=fields_out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _classify_type(hint: Any) -> tuple[str, bool, list[str] | None]:
|
|
126
|
+
"""Return (field_kind, optional, choices-if-enum) for a type hint."""
|
|
127
|
+
origin = typing.get_origin(hint)
|
|
128
|
+
args = typing.get_args(hint)
|
|
129
|
+
|
|
130
|
+
# Optional[X] — unwrap, mark optional, recurse. Accept both spellings:
|
|
131
|
+
# typing.Optional/Union[X, None] (origin is typing.Union) and the PEP 604
|
|
132
|
+
# form X | None (origin is types.UnionType on 3.10+).
|
|
133
|
+
if origin in (Union, types.UnionType) and type(None) in args:
|
|
134
|
+
non_none = [a for a in args if a is not type(None)]
|
|
135
|
+
if len(non_none) == 1:
|
|
136
|
+
inner_kind, _, choices = _classify_type(non_none[0])
|
|
137
|
+
return inner_kind, True, choices
|
|
138
|
+
# Multi-type Optional union: give up, passthrough.
|
|
139
|
+
return "passthrough", True, None
|
|
140
|
+
|
|
141
|
+
# Literal[...] — enum.
|
|
142
|
+
if origin is typing.Literal:
|
|
143
|
+
return "enum", False, [str(a) for a in args]
|
|
144
|
+
|
|
145
|
+
# Plain scalars.
|
|
146
|
+
if hint is float:
|
|
147
|
+
return "float", False, None
|
|
148
|
+
if hint is Sides:
|
|
149
|
+
return "sides", False, None
|
|
150
|
+
if hint is bool:
|
|
151
|
+
return "bool", False, None
|
|
152
|
+
if hint is int:
|
|
153
|
+
return "int", False, None
|
|
154
|
+
if hint is str:
|
|
155
|
+
return "str", False, None
|
|
156
|
+
|
|
157
|
+
# Everything else (list[dict], nested dataclass, forward ref, ...).
|
|
158
|
+
return "passthrough", False, None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _field_default(f: dataclasses.Field) -> Any:
|
|
162
|
+
if f.default is not dataclasses.MISSING:
|
|
163
|
+
if isinstance(f.default, Sides):
|
|
164
|
+
# As a spec would hold it: the number while the sides are equal.
|
|
165
|
+
return as_written(f.default)
|
|
166
|
+
return f.default
|
|
167
|
+
if f.default_factory is not dataclasses.MISSING:
|
|
168
|
+
try:
|
|
169
|
+
return f.default_factory()
|
|
170
|
+
except Exception:
|
|
171
|
+
return None
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _titleize(name: str) -> str:
|
|
176
|
+
return name.replace("_", " ").strip().capitalize()
|