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,488 @@
|
|
|
1
|
+
"""Orchestrate test discovery: find test models and merge parameters."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
9
|
+
|
|
10
|
+
from ..config import (
|
|
11
|
+
DEFAULT_METHOD,
|
|
12
|
+
DEFAULT_STOP_TIME,
|
|
13
|
+
DEFAULT_TOLERANCE,
|
|
14
|
+
Config,
|
|
15
|
+
)
|
|
16
|
+
from .recognizer import RecognizerResult, get_recognizers
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..comparison.tree_spec import SpecNode
|
|
20
|
+
from .mo_parser import LocatedClass
|
|
21
|
+
from .plots import PlotExtra
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TestModel:
|
|
28
|
+
"""A fully resolved test model with all metadata needed for simulation."""
|
|
29
|
+
|
|
30
|
+
model_id: str
|
|
31
|
+
source_file: Path
|
|
32
|
+
source_package: str
|
|
33
|
+
short_name: str
|
|
34
|
+
n_vars: int
|
|
35
|
+
# The UnitTests component's instance name: its variables are
|
|
36
|
+
# ``<instance>.x[i]`` in the result file. A test with no such component
|
|
37
|
+
# (``source == "spec"``) keeps the conventional default, and
|
|
38
|
+
# ``unit_test_variables`` returns nothing for it, so that default never
|
|
39
|
+
# ends up naming anything.
|
|
40
|
+
unit_tests_instance: str = "unitTests"
|
|
41
|
+
x_expressions: list[str] = field(default_factory=list)
|
|
42
|
+
x_raw: str = ""
|
|
43
|
+
x_reference: list[float] | None = None
|
|
44
|
+
error_expected: float = 1e-6
|
|
45
|
+
# Simulation fields default to None = "not set by any source" so the
|
|
46
|
+
# spec-over-annotation merge can use `is not None` as the explicitly-set
|
|
47
|
+
# sentinel, exactly like number_of_intervals / output_interval — review
|
|
48
|
+
# 2026-07-06 finding 13: equality-with-default sentinels silently dropped
|
|
49
|
+
# a spec value that happened to equal the framework default. During
|
|
50
|
+
# discovery they may be None; ``finalize_defaults()`` (called at the end
|
|
51
|
+
# of discover_tests) resolves them to the framework defaults so
|
|
52
|
+
# downstream consumers (backends, reporters, storage) only ever see
|
|
53
|
+
# concrete values.
|
|
54
|
+
stop_time: float | None = None
|
|
55
|
+
tolerance: float | None = None
|
|
56
|
+
method: str | None = None
|
|
57
|
+
number_of_intervals: int | None = None
|
|
58
|
+
output_interval: float | None = None
|
|
59
|
+
result_file: str = ""
|
|
60
|
+
timeout: int | None = None # Per-test timeout override (seconds)
|
|
61
|
+
|
|
62
|
+
# External spec: variable patterns (may include globs like "medium.T*" or "*")
|
|
63
|
+
# These are resolved against actual .mat variable names after simulation.
|
|
64
|
+
variable_patterns: list[str] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
# Comparison settings (per-test and per-variable overrides)
|
|
67
|
+
#: Overrides config.tolerance. As the spec wrote it: a value that is no
|
|
68
|
+
#: number is carried to ``compare_test``, which refuses the test.
|
|
69
|
+
comparison_tolerance: Any = None
|
|
70
|
+
variable_overrides: dict[str, dict] = field(default_factory=dict)
|
|
71
|
+
# variable_overrides format: {"var_name": {"tolerance": 0.1}, ...}
|
|
72
|
+
|
|
73
|
+
# Phase 3.2: optional user-authored MetricTree spec from test_spec.json
|
|
74
|
+
# under the "metrics" key. When set, Phase 3.3+ will use this in place
|
|
75
|
+
# of the implicit flat-AND. Parse-only today — no behavior gated on it.
|
|
76
|
+
metric_tree_spec: Optional["SpecNode"] = None
|
|
77
|
+
|
|
78
|
+
# The entry's optional ``plots`` extras (`discovery.plots`): context
|
|
79
|
+
# series and watch plots beside the plots derived from the tree. None
|
|
80
|
+
# when the key is absent, ``()`` for ``[]`` — both mean no extras.
|
|
81
|
+
plots: tuple["PlotExtra", ...] | None = None
|
|
82
|
+
|
|
83
|
+
# The entry's own ``baseline_points`` (D153): ``"all"`` or a whole number
|
|
84
|
+
# of 2 or more — the points this test's stored baseline keeps of its
|
|
85
|
+
# run. None when the entry has no such key: the project's applies
|
|
86
|
+
# (``Config.baseline_points``).
|
|
87
|
+
baseline_points: int | str | None = None
|
|
88
|
+
|
|
89
|
+
# PTA.4 — richer-contract fields settable via recognizers. Additive;
|
|
90
|
+
# defaults preserve pre-PTA behavior.
|
|
91
|
+
#
|
|
92
|
+
# simulate_only: a recognizer's MARK — the test passes iff the simulation
|
|
93
|
+
# completes without error, whatever it declares, and no criterion staged
|
|
94
|
+
# on it is ever scored. What a reader asks is `is_simulate_only`, which
|
|
95
|
+
# is also true of a test that declares nothing to score.
|
|
96
|
+
simulate_only: bool = False
|
|
97
|
+
# requested_baselines: names of additional baselines to produce for this
|
|
98
|
+
# test (e.g., ["dymola-via-fmpy"]). Placeholder for 4.B.
|
|
99
|
+
requested_baselines: list[str] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
# Per-field provenance: where the value came from.
|
|
102
|
+
# Keys: "stop_time", "tolerance", "method", "number_of_intervals",
|
|
103
|
+
# "output_interval". Values: "annotation", "test_spec", "default".
|
|
104
|
+
# Populated during recognizer + spec merge so the dashboard's
|
|
105
|
+
# resolution-explainer column can show "stop_time: 10 (annotation)".
|
|
106
|
+
field_sources: dict[str, str] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
# Where this test was defined: "unit_tests", "spec", "both"
|
|
109
|
+
source: str = "unit_tests"
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_simulate_only(self) -> bool:
|
|
113
|
+
"""Whether the test passes iff its simulation completes — THE one
|
|
114
|
+
rule: a recognizer marked it (``simulate_only``), or it DECLARES
|
|
115
|
+
nothing to score: no UnitTests variable, no ``variables`` pattern,
|
|
116
|
+
no ``metrics`` tree and no ``comparison.variable_overrides`` entry.
|
|
117
|
+
That is the entry ``dstf add <model>`` writes (``"variables": []``),
|
|
118
|
+
which has no key of its own for it.
|
|
119
|
+
|
|
120
|
+
An override is a declaration: whoever wrote ``{"x": {"mode":
|
|
121
|
+
"range", ...}}`` asked for ``x`` to be checked. On an entry that
|
|
122
|
+
tracks nothing it scores nothing — an override changes how a TRACKED
|
|
123
|
+
variable is scored — and such a test passed, simulate-only, with no
|
|
124
|
+
word of the bound it never read.
|
|
125
|
+
|
|
126
|
+
Not the test that declares variables and whose result holds none of
|
|
127
|
+
them, nor the one whose overrides are all it declares: each scored
|
|
128
|
+
nothing it asked about, and fails
|
|
129
|
+
(``comparison.comparator.nothing_scored``)."""
|
|
130
|
+
from .unit_tests import unit_test_variables
|
|
131
|
+
|
|
132
|
+
if self.simulate_only:
|
|
133
|
+
return True
|
|
134
|
+
return (
|
|
135
|
+
not unit_test_variables(self)
|
|
136
|
+
and not self.variable_patterns
|
|
137
|
+
and self.metric_tree_spec is None
|
|
138
|
+
and not self.variable_overrides
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def finalize_defaults(self) -> "TestModel":
|
|
142
|
+
"""Resolve unset (None) simulation fields to framework defaults.
|
|
143
|
+
|
|
144
|
+
Called once, at the END of discover_tests — review 2026-07-06
|
|
145
|
+
finding 13: only the merge logic distinguishes "unset" (None) from
|
|
146
|
+
"explicitly the default value"; everything downstream sees concrete
|
|
147
|
+
values. Also stamps provenance "default" for the five sim fields no
|
|
148
|
+
source set (finding 57: spec-only tests used to claim "test_spec"
|
|
149
|
+
provenance for pure framework defaults).
|
|
150
|
+
"""
|
|
151
|
+
if self.stop_time is None:
|
|
152
|
+
self.stop_time = DEFAULT_STOP_TIME
|
|
153
|
+
if self.tolerance is None:
|
|
154
|
+
self.tolerance = DEFAULT_TOLERANCE
|
|
155
|
+
if self.method is None:
|
|
156
|
+
self.method = DEFAULT_METHOD
|
|
157
|
+
for fname in (
|
|
158
|
+
"stop_time",
|
|
159
|
+
"tolerance",
|
|
160
|
+
"method",
|
|
161
|
+
"number_of_intervals",
|
|
162
|
+
"output_interval",
|
|
163
|
+
):
|
|
164
|
+
self.field_sources.setdefault(fname, "default")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_test_model_from_recognizer_results(
|
|
169
|
+
model_id: str,
|
|
170
|
+
results: list[RecognizerResult],
|
|
171
|
+
) -> TestModel:
|
|
172
|
+
"""Merge per-recognizer results for one model into a TestModel.
|
|
173
|
+
|
|
174
|
+
Later results override earlier ones per-field. The bundled recognizer
|
|
175
|
+
registers first; user-provided recognizers (PTA.3) append, so user values
|
|
176
|
+
win on conflicts. Fields no recognizer set fall back to TestModel's
|
|
177
|
+
dataclass defaults.
|
|
178
|
+
|
|
179
|
+
The UnitTests variables are the ones a recognizer DECLARED: ``n_vars``
|
|
180
|
+
when one gave it, else as many as the expressions one gave, else none.
|
|
181
|
+
A test recognized by something other than the component (``extends
|
|
182
|
+
*Icons.Example``) has no ``<instance>.x[i]``, and a default of one made
|
|
183
|
+
every run look for ``unitTests.x[1]`` in its result and warn.
|
|
184
|
+
"""
|
|
185
|
+
parts = model_id.rsplit(".", 1)
|
|
186
|
+
source_package = parts[0] if len(parts) > 1 else ""
|
|
187
|
+
short_name = parts[-1]
|
|
188
|
+
|
|
189
|
+
model = TestModel(
|
|
190
|
+
model_id=model_id,
|
|
191
|
+
source_file=Path(""),
|
|
192
|
+
source_package=source_package,
|
|
193
|
+
short_name=short_name,
|
|
194
|
+
n_vars=0,
|
|
195
|
+
result_file=short_name,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
counted = False
|
|
199
|
+
for r in results:
|
|
200
|
+
if r.source_file is not None:
|
|
201
|
+
model.source_file = r.source_file
|
|
202
|
+
if r.n_vars is not None:
|
|
203
|
+
model.n_vars = r.n_vars
|
|
204
|
+
counted = True
|
|
205
|
+
if r.unit_tests_instance:
|
|
206
|
+
model.unit_tests_instance = r.unit_tests_instance
|
|
207
|
+
if r.x_expressions:
|
|
208
|
+
model.x_expressions = list(r.x_expressions)
|
|
209
|
+
if r.x_raw:
|
|
210
|
+
model.x_raw = r.x_raw
|
|
211
|
+
if r.x_reference is not None:
|
|
212
|
+
model.x_reference = list(r.x_reference)
|
|
213
|
+
if r.error_expected is not None:
|
|
214
|
+
model.error_expected = r.error_expected
|
|
215
|
+
if r.stop_time is not None:
|
|
216
|
+
model.stop_time = r.stop_time
|
|
217
|
+
model.field_sources["stop_time"] = "annotation"
|
|
218
|
+
if r.tolerance is not None:
|
|
219
|
+
model.tolerance = r.tolerance
|
|
220
|
+
model.field_sources["tolerance"] = "annotation"
|
|
221
|
+
if r.method is not None:
|
|
222
|
+
model.method = r.method
|
|
223
|
+
model.field_sources["method"] = "annotation"
|
|
224
|
+
if r.number_of_intervals is not None:
|
|
225
|
+
model.number_of_intervals = r.number_of_intervals
|
|
226
|
+
model.field_sources["number_of_intervals"] = "annotation"
|
|
227
|
+
if r.output_interval is not None:
|
|
228
|
+
model.output_interval = r.output_interval
|
|
229
|
+
model.field_sources["output_interval"] = "annotation"
|
|
230
|
+
if r.simulate_only is not None:
|
|
231
|
+
model.simulate_only = r.simulate_only
|
|
232
|
+
if r.requested_baselines is not None:
|
|
233
|
+
model.requested_baselines = list(r.requested_baselines)
|
|
234
|
+
|
|
235
|
+
if not counted:
|
|
236
|
+
model.n_vars = len(model.x_expressions)
|
|
237
|
+
return model
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def scan_source_tests(
|
|
241
|
+
config: Config, progress: Callable[[int, int], None] | None = None
|
|
242
|
+
) -> dict[str, TestModel]:
|
|
243
|
+
"""Step 1 of discovery: recognizer results over the source tree, keyed
|
|
244
|
+
by model_id. Empty unless ``source_type == "modelica"``. ``progress(done,
|
|
245
|
+
total)`` is called after each ``.mo`` file is scanned."""
|
|
246
|
+
if config.source_type != "modelica":
|
|
247
|
+
return {}
|
|
248
|
+
return scan_files(config, sorted(config.library_dir.rglob("*.mo")), progress)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def scan_files(
|
|
252
|
+
config: Config,
|
|
253
|
+
files: Iterable[Path],
|
|
254
|
+
progress: Callable[[int, int], None] | None = None,
|
|
255
|
+
) -> dict[str, TestModel]:
|
|
256
|
+
"""The tests the recognizers find in ``files`` of a Modelica library,
|
|
257
|
+
keyed by model_id: :func:`scan_source_tests` over every ``.mo`` file of
|
|
258
|
+
it. ``progress(done, total)`` is called after each file."""
|
|
259
|
+
# Trigger bundled-recognizer registration on first call.
|
|
260
|
+
from . import mo_parser # noqa: F401
|
|
261
|
+
|
|
262
|
+
# Bundled recognizers come from the module-level registry (filtered by
|
|
263
|
+
# config.disabled_bundled); user-provided recognizers from config.recognizers
|
|
264
|
+
# append, so user values win on per-field merge in
|
|
265
|
+
# _build_test_model_from_recognizer_results.
|
|
266
|
+
library_dir = config.library_dir
|
|
267
|
+
bundled = [
|
|
268
|
+
r for r in get_recognizers("modelica") if r.name not in config.disabled_bundled
|
|
269
|
+
]
|
|
270
|
+
user = [r for r in config.recognizers if "modelica" in r.applies_to]
|
|
271
|
+
recognizers = bundled + user
|
|
272
|
+
|
|
273
|
+
files = list(files)
|
|
274
|
+
per_model: dict[str, list[RecognizerResult]] = {}
|
|
275
|
+
for done, mo_file in enumerate(files, start=1):
|
|
276
|
+
for recognizer in recognizers:
|
|
277
|
+
if not recognizer.applies_to_path(mo_file, library_dir):
|
|
278
|
+
continue
|
|
279
|
+
result = recognizer.recognize(mo_file)
|
|
280
|
+
if result is not None:
|
|
281
|
+
per_model.setdefault(result.model_id, []).append(result)
|
|
282
|
+
if progress is not None:
|
|
283
|
+
progress(done, len(files))
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
model_id: _build_test_model_from_recognizer_results(model_id, results)
|
|
287
|
+
for model_id, results in per_model.items()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def spec_tests_by_model(models: list[TestModel]) -> dict[str, TestModel]:
|
|
292
|
+
"""Step 2: the spec's models keyed by model_id, first entry wins on a
|
|
293
|
+
duplicate (matching patch_apply / the spec edit helpers), with a warning."""
|
|
294
|
+
spec_tests: dict[str, TestModel] = {}
|
|
295
|
+
for test in models:
|
|
296
|
+
# review 2026-07-06 finding 51: duplicate model entries — FIRST
|
|
297
|
+
# wins (matching patch_apply / the spec edit helpers), with a
|
|
298
|
+
# warning. Last-wins here made applied patches silently
|
|
299
|
+
# ineffective (they edit the first entry).
|
|
300
|
+
if test.model_id in spec_tests:
|
|
301
|
+
logger.warning(
|
|
302
|
+
"test_spec has duplicate entries for model '%s'; "
|
|
303
|
+
"using the first — remove the duplicates",
|
|
304
|
+
test.model_id,
|
|
305
|
+
)
|
|
306
|
+
continue
|
|
307
|
+
spec_tests[test.model_id] = test
|
|
308
|
+
return spec_tests
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
#: The simulation fields an ``experiment(...)`` annotation can give.
|
|
312
|
+
_SIMULATION_FIELDS = (
|
|
313
|
+
"stop_time",
|
|
314
|
+
"tolerance",
|
|
315
|
+
"method",
|
|
316
|
+
"number_of_intervals",
|
|
317
|
+
"output_interval",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SpecModelLocator:
|
|
322
|
+
"""Finds the model of a test that only ``test_spec.json`` declares: its
|
|
323
|
+
file and its ``experiment(...)`` annotation
|
|
324
|
+
(:func:`~.mo_parser.find_class`), which :func:`merge_tests` fills the
|
|
325
|
+
entry's unset simulation fields from.
|
|
326
|
+
|
|
327
|
+
Modelica sources only — any other source type has no annotation to
|
|
328
|
+
read, and every ask answers None.
|
|
329
|
+
|
|
330
|
+
Each model is looked for ONCE per locator, a miss included: the
|
|
331
|
+
authoring Session re-merges on every edit of the spec, and on a slow
|
|
332
|
+
mount the probes for a few spec-only models are not something to repeat
|
|
333
|
+
at every keystroke. A locator therefore shows a model file as it was
|
|
334
|
+
when first asked; whoever holds one replaces it when the library is
|
|
335
|
+
scanned again, or takes one that knows better (:meth:`having`) when it
|
|
336
|
+
read a model's file again itself.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(self, config: Config):
|
|
340
|
+
self._config = config
|
|
341
|
+
self._found: dict[str, LocatedClass | None] = {}
|
|
342
|
+
|
|
343
|
+
def having(self, found: Mapping[str, "LocatedClass"]) -> "SpecModelLocator":
|
|
344
|
+
"""A locator that answers *found* for those models and, for every
|
|
345
|
+
other, what this one has found so far (and looks for the rest)."""
|
|
346
|
+
other = SpecModelLocator(self._config)
|
|
347
|
+
other._found = {**self._found, **found}
|
|
348
|
+
return other
|
|
349
|
+
|
|
350
|
+
def __call__(self, model_id: str) -> "LocatedClass | None":
|
|
351
|
+
if self._config.source_type != "modelica":
|
|
352
|
+
return None
|
|
353
|
+
if model_id not in self._found:
|
|
354
|
+
from .mo_parser import find_class
|
|
355
|
+
|
|
356
|
+
config = self._config
|
|
357
|
+
assert config.library_name is not None # resolved in __post_init__
|
|
358
|
+
self._found[model_id] = find_class(
|
|
359
|
+
config.library_dir, config.library_name, model_id
|
|
360
|
+
)
|
|
361
|
+
return self._found[model_id]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _fill_from_model(test: TestModel, located: "LocatedClass") -> None:
|
|
365
|
+
"""Give a spec-only ``test`` what its model says: the file, and every
|
|
366
|
+
simulation field the entry left unset that the model's ``experiment``
|
|
367
|
+
annotation sets — the two-layer contract a scanned test already gets
|
|
368
|
+
(docs/usage.md). The entry's own ``simulation.*`` and ``source`` stay."""
|
|
369
|
+
if test.source_file == Path(""):
|
|
370
|
+
test.source_file = located.mo_file
|
|
371
|
+
experiment = located.experiment
|
|
372
|
+
if experiment is None:
|
|
373
|
+
return
|
|
374
|
+
for name in _SIMULATION_FIELDS:
|
|
375
|
+
value = getattr(experiment, name)
|
|
376
|
+
if getattr(test, name) is None and value is not None:
|
|
377
|
+
setattr(test, name, value)
|
|
378
|
+
test.field_sources[name] = "annotation"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def merge_tests(
|
|
382
|
+
scanned: dict[str, TestModel],
|
|
383
|
+
spec_tests: dict[str, TestModel],
|
|
384
|
+
*,
|
|
385
|
+
locate: Callable[[str], "LocatedClass | None"] | None = None,
|
|
386
|
+
) -> list[TestModel]:
|
|
387
|
+
"""Step 3: union by model_id; the spec wins per field. Copies both
|
|
388
|
+
inputs, so a caller (the authoring Session) can keep one scan and
|
|
389
|
+
re-merge it against every edit of the spec.
|
|
390
|
+
|
|
391
|
+
``locate`` finds the model of an entry the scan did not recognize
|
|
392
|
+
(:class:`SpecModelLocator`); such a test takes its model's file and
|
|
393
|
+
annotation. Without it a spec-only entry is taken as written."""
|
|
394
|
+
merged: dict[str, TestModel] = {
|
|
395
|
+
model_id: copy.deepcopy(test) for model_id, test in scanned.items()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
# Merge spec tests. Two-layer contract (docs/usage.md): the model's
|
|
399
|
+
# experiment(...) annotation provides defaults; any field set in
|
|
400
|
+
# simulation.* overrides it. DECISION (review 2026-07-06 finding 13):
|
|
401
|
+
# "explicitly set in the spec" is `is not None` for ALL five sim fields —
|
|
402
|
+
# never equality with a framework default, which silently dropped a spec
|
|
403
|
+
# value like stop_time=1.0 against experiment(StopTime=100). Framework
|
|
404
|
+
# defaults are applied only afterwards, by finalize_defaults() below.
|
|
405
|
+
for model_id, spec_test in spec_tests.items():
|
|
406
|
+
spec_test = copy.deepcopy(spec_test)
|
|
407
|
+
if model_id in merged:
|
|
408
|
+
existing = merged[model_id]
|
|
409
|
+
existing.variable_patterns = spec_test.variable_patterns
|
|
410
|
+
existing.source = "both"
|
|
411
|
+
if spec_test.stop_time is not None:
|
|
412
|
+
existing.stop_time = spec_test.stop_time
|
|
413
|
+
existing.field_sources["stop_time"] = "test_spec"
|
|
414
|
+
if spec_test.tolerance is not None:
|
|
415
|
+
existing.tolerance = spec_test.tolerance
|
|
416
|
+
existing.field_sources["tolerance"] = "test_spec"
|
|
417
|
+
if spec_test.method is not None:
|
|
418
|
+
existing.method = spec_test.method
|
|
419
|
+
existing.field_sources["method"] = "test_spec"
|
|
420
|
+
if spec_test.number_of_intervals is not None:
|
|
421
|
+
existing.number_of_intervals = spec_test.number_of_intervals
|
|
422
|
+
existing.field_sources["number_of_intervals"] = "test_spec"
|
|
423
|
+
if spec_test.output_interval is not None:
|
|
424
|
+
existing.output_interval = spec_test.output_interval
|
|
425
|
+
existing.field_sources["output_interval"] = "test_spec"
|
|
426
|
+
if spec_test.comparison_tolerance is not None:
|
|
427
|
+
existing.comparison_tolerance = spec_test.comparison_tolerance
|
|
428
|
+
if spec_test.variable_overrides:
|
|
429
|
+
existing.variable_overrides.update(spec_test.variable_overrides)
|
|
430
|
+
if spec_test.timeout is not None:
|
|
431
|
+
existing.timeout = spec_test.timeout
|
|
432
|
+
if spec_test.metric_tree_spec is not None:
|
|
433
|
+
existing.metric_tree_spec = spec_test.metric_tree_spec
|
|
434
|
+
if spec_test.plots is not None:
|
|
435
|
+
existing.plots = spec_test.plots
|
|
436
|
+
if spec_test.baseline_points is not None:
|
|
437
|
+
existing.baseline_points = spec_test.baseline_points
|
|
438
|
+
else:
|
|
439
|
+
# Provenance for spec-set fields was stamped by parse_test_spec;
|
|
440
|
+
# unset fields get "default" in finalize_defaults — review
|
|
441
|
+
# 2026-07-06 finding 57 (the old blanket "test_spec" stamp lied).
|
|
442
|
+
located = locate(model_id) if locate is not None else None
|
|
443
|
+
if located is not None:
|
|
444
|
+
_fill_from_model(spec_test, located)
|
|
445
|
+
merged[model_id] = spec_test
|
|
446
|
+
|
|
447
|
+
# Sort by model_id for consistent ordering; resolve remaining None sim
|
|
448
|
+
# fields to framework defaults so downstream code never sees None
|
|
449
|
+
# (finding 13 decision: apply-defaults-at-end-of-merge).
|
|
450
|
+
tests = sorted(merged.values(), key=lambda t: t.model_id)
|
|
451
|
+
for test in tests:
|
|
452
|
+
test.finalize_defaults()
|
|
453
|
+
return tests
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def discover_tests(config: Config) -> list[TestModel]:
|
|
457
|
+
"""Discover all test models from registered recognizers and/or external spec.
|
|
458
|
+
|
|
459
|
+
Sources (merged by model_id):
|
|
460
|
+
1. Source-file recognizers (PTA.1+) — for ``source_type == "modelica"``,
|
|
461
|
+
walks ``*.mo`` and runs every registered Modelica recognizer. Bundled
|
|
462
|
+
default is the ``UnitTests`` + ``experiment(...)`` recognizer; user-
|
|
463
|
+
provided recognizers (PTA.3) layer on top.
|
|
464
|
+
2. External test_spec.json (the configured file, else
|
|
465
|
+
``<reference_root>/test_spec.json`` when it exists; overrides
|
|
466
|
+
recognizer values). A model only the spec names is looked up in the
|
|
467
|
+
library (:class:`SpecModelLocator`) for its file and its
|
|
468
|
+
``experiment`` annotation.
|
|
469
|
+
|
|
470
|
+
When a model appears in both recognizers and spec, variable patterns
|
|
471
|
+
from the spec are added alongside the recognizer-derived variables, and
|
|
472
|
+
the source is marked as "both". Spec simulation parameters override
|
|
473
|
+
recognizer values when explicitly set.
|
|
474
|
+
|
|
475
|
+
The three steps are separable: the authoring Session keeps one
|
|
476
|
+
:func:`scan_source_tests` result and re-runs :func:`merge_tests` against
|
|
477
|
+
its in-memory spec document on every edit.
|
|
478
|
+
"""
|
|
479
|
+
scanned = scan_source_tests(config)
|
|
480
|
+
if config.spec_path.exists():
|
|
481
|
+
from .spec_parser import parse_test_spec
|
|
482
|
+
|
|
483
|
+
models = parse_test_spec(config.spec_path)
|
|
484
|
+
else:
|
|
485
|
+
models = []
|
|
486
|
+
return merge_tests(
|
|
487
|
+
scanned, spec_tests_by_model(models), locate=SpecModelLocator(config)
|
|
488
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The one place a UnitTests component's variables are named.
|
|
2
|
+
|
|
3
|
+
A variable's NAME is the one it has in the result file —
|
|
4
|
+
``<instance>.x[i]``. The text the model wrote in ``x={...}`` is its
|
|
5
|
+
EXPRESSION: a label that rides beside the name, never a key. The
|
|
6
|
+
backends that extract a result, and the variable filter OpenModelica
|
|
7
|
+
simulates under, ask here rather than spelling ``unitTests.x[i]``
|
|
8
|
+
themselves.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .test_registry import TestModel
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#: The array the component tracks, ``x={...}`` (see :func:`unit_test_variables`).
|
|
22
|
+
_ARRAY = "x"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unit_test_name(instance: str, index: int) -> str:
|
|
26
|
+
"""``<instance>.x[i]`` — the result-file name of element ``index``."""
|
|
27
|
+
return f"{instance}.{_ARRAY}[{index}]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_unit_test_variable(test: TestModel, name: str) -> bool:
|
|
31
|
+
"""Whether ``name`` is an element of the test's UnitTests component,
|
|
32
|
+
``<instance>.x[i]`` — declared through the component or tracked by that
|
|
33
|
+
name. Each is its own test quantity, whatever its expression reads as,
|
|
34
|
+
so no plot folds it into a family with another (the owner's ruling O3,
|
|
35
|
+
2026-09-28)."""
|
|
36
|
+
head = f"{test.unit_tests_instance}.{_ARRAY}["
|
|
37
|
+
# ASCII digits: `str.isdigit` takes `²` and `١` for one too
|
|
38
|
+
return (
|
|
39
|
+
name.startswith(head)
|
|
40
|
+
and name.endswith("]")
|
|
41
|
+
and re.fullmatch(r"[0-9]+", name[len(head) : -1]) is not None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class UnitTestVariable:
|
|
47
|
+
"""One tracked variable of the component: its result-file name and the
|
|
48
|
+
expression the model wrote for it (``""`` when nothing said which
|
|
49
|
+
expression belongs to which index)."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
expression: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unit_test_variables(test: TestModel) -> list[UnitTestVariable]:
|
|
56
|
+
"""The component's variables in index order, empty for a test with no
|
|
57
|
+
such component (``source == "spec"``, or no variables declared).
|
|
58
|
+
|
|
59
|
+
The component is assumed to name its tracked array ``x`` — the bundled
|
|
60
|
+
one does, and a custom component reached through a JSON recognizer is
|
|
61
|
+
taken to follow it.
|
|
62
|
+
|
|
63
|
+
The expression is ``x_expressions[i - 1]`` when the model wrote one per
|
|
64
|
+
variable, and ``f"{x_expressions[0]}[{i}]"`` when it wrote a SINGLE
|
|
65
|
+
expression covering all of them — an array (``x=y``) or a concatenation
|
|
66
|
+
(``x=cat(1, a, b)``), whose element ``i`` reads as ``y[i]`` /
|
|
67
|
+
``cat(1, a, b)[i]``.
|
|
68
|
+
|
|
69
|
+
Any other shape leaves the expression ``""``: two expressions for three
|
|
70
|
+
variables says nothing about which piece is which, and a wrong label is
|
|
71
|
+
worse than none.
|
|
72
|
+
"""
|
|
73
|
+
if test.source not in ("unit_tests", "both") or test.n_vars <= 0:
|
|
74
|
+
return []
|
|
75
|
+
exprs = test.x_expressions
|
|
76
|
+
out: list[UnitTestVariable] = []
|
|
77
|
+
for i in range(1, test.n_vars + 1):
|
|
78
|
+
if len(exprs) == test.n_vars:
|
|
79
|
+
expression = exprs[i - 1]
|
|
80
|
+
elif len(exprs) == 1:
|
|
81
|
+
expression = f"{exprs[0]}[{i}]"
|
|
82
|
+
else:
|
|
83
|
+
expression = ""
|
|
84
|
+
out.append(
|
|
85
|
+
UnitTestVariable(unit_test_name(test.unit_tests_instance, i), expression)
|
|
86
|
+
)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"UnitTestVariable",
|
|
92
|
+
"is_unit_test_variable",
|
|
93
|
+
"unit_test_name",
|
|
94
|
+
"unit_test_variables",
|
|
95
|
+
]
|
dstf/figures/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""One figure layer, two consumers.
|
|
2
|
+
|
|
3
|
+
A :class:`~.view.VariableView` is everything a plot of one variable needs —
|
|
4
|
+
trajectories, overlays, the leaves that target it with their geometry and
|
|
5
|
+
summaries, the error series — assembled the same way by the HTML report
|
|
6
|
+
and by the authoring tool. :func:`~.plotly.variable_figure` renders it to a
|
|
7
|
+
plain Plotly figure dict (``data`` / ``layout`` / ``config``), with no
|
|
8
|
+
``plotly.py`` dependency; the report embeds the dict, the tool hands it to
|
|
9
|
+
its plotly element. :func:`~.plotly.plot_figure` renders a plot of SEVERAL
|
|
10
|
+
views the same way — array families in one hue ramp (:mod:`.families`) —
|
|
11
|
+
and is ``variable_figure`` itself when a plot holds one. Nothing here knows
|
|
12
|
+
a mode name: drawing is keyed on primitive type
|
|
13
|
+
(:mod:`dstf.comparison.geometry`).
|
|
14
|
+
"""
|
dstf/figures/assemble.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""From a comparison and a trajectory to a VariableView — the assembly the
|
|
2
|
+
report's figure build and the authoring Session's ``view`` share."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from ..comparison.types import VariableComparison
|
|
9
|
+
from .view import VariableView, build_view
|
|
10
|
+
|
|
11
|
+
LeafPair = tuple[str, VariableComparison]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def leaves_by_variable(metric_tree) -> dict[str, list[LeafPair]]:
|
|
15
|
+
"""Every scored leaf, keyed by the variable it scores, as the
|
|
16
|
+
``(leaf_path, comparison)`` pairs ``build_view`` takes. Empty for
|
|
17
|
+
``None``."""
|
|
18
|
+
from ..comparison.tree_eval import leaf_comparisons_by_path
|
|
19
|
+
|
|
20
|
+
out: dict[str, list[LeafPair]] = {}
|
|
21
|
+
if metric_tree is None:
|
|
22
|
+
return out
|
|
23
|
+
for path, vc in leaf_comparisons_by_path(metric_tree).items():
|
|
24
|
+
out.setdefault(vc.name, []).append((path, vc))
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def view_from_trajectory(
|
|
29
|
+
name: str, trajectory: dict, leaves: Sequence[LeafPair]
|
|
30
|
+
) -> VariableView:
|
|
31
|
+
"""The view for one variable from a report-shaped trajectory entry
|
|
32
|
+
(``act_time`` / ``act_values`` / ``ref_time`` / ``ref_values`` /
|
|
33
|
+
``text`` / ``overlays``, any of them absent).
|
|
34
|
+
|
|
35
|
+
The view's label is the trajectory's ``text`` — what the test's page
|
|
36
|
+
reads for the variable — not its ``label``: the plain label belongs to
|
|
37
|
+
the surfaces that show the muted name beside it, and a figure's legend
|
|
38
|
+
and hover show no name."""
|
|
39
|
+
return build_view(
|
|
40
|
+
name,
|
|
41
|
+
label=trajectory.get("text") or trajectory.get("label") or name,
|
|
42
|
+
act_time=trajectory.get("act_time"),
|
|
43
|
+
act_values=trajectory.get("act_values"),
|
|
44
|
+
ref_time=trajectory.get("ref_time"),
|
|
45
|
+
ref_values=trajectory.get("ref_values"),
|
|
46
|
+
overlays=trajectory.get("overlays") or [],
|
|
47
|
+
leaves=leaves,
|
|
48
|
+
)
|