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,167 @@
|
|
|
1
|
+
# Framework-generated Julia driver for the PERSISTENT worker path (D78).
|
|
2
|
+
#
|
|
3
|
+
# A long-lived Julia process reads JSON-per-line test requests from stdin
|
|
4
|
+
# and writes JSON-per-line responses to stdout. Package load (MTK, ODE,
|
|
5
|
+
# JSON3) happens ONCE at startup; per-test cost becomes just
|
|
6
|
+
# ``include(user_file)`` + ``structural_simplify`` + ``solve``.
|
|
7
|
+
#
|
|
8
|
+
# Request:
|
|
9
|
+
# {"user_file": "...", "stop_time": 10.0, "tolerance": 1e-6,
|
|
10
|
+
# "result_path": "...", "test_key": "..."}
|
|
11
|
+
# Response:
|
|
12
|
+
# {"test_key": "...", "status": "ok"|"fail", "elapsed": 12.3, "error": "..."}
|
|
13
|
+
#
|
|
14
|
+
# Control messages:
|
|
15
|
+
# {"cmd": "quit"} → exits cleanly.
|
|
16
|
+
# {"cmd": "ready"} → emits a ready pulse (used for startup handshake).
|
|
17
|
+
#
|
|
18
|
+
# Re-inclusion: ``include`` re-evaluates the user file at module scope,
|
|
19
|
+
# redefining ``build_mtk_system``. ``Base.invokelatest`` forces the
|
|
20
|
+
# generation boundary so the freshly-redefined function is called rather
|
|
21
|
+
# than an older compiled version the worker might still have bound.
|
|
22
|
+
|
|
23
|
+
using Printf
|
|
24
|
+
using JSON3
|
|
25
|
+
using ModelingToolkit
|
|
26
|
+
using OrdinaryDiffEq
|
|
27
|
+
|
|
28
|
+
# Signal readiness to the dispatcher. The parent can use this as a hand-
|
|
29
|
+
# shake marker indicating `using` has completed and the worker is ready
|
|
30
|
+
# to accept test requests.
|
|
31
|
+
println(stdout, JSON3.write(Dict("event" => "ready", "pid" => getpid())))
|
|
32
|
+
flush(stdout)
|
|
33
|
+
|
|
34
|
+
function handle_request(req)
|
|
35
|
+
user_file = req["user_file"]
|
|
36
|
+
stop_time = Float64(req["stop_time"])
|
|
37
|
+
tol = Float64(req["tolerance"])
|
|
38
|
+
result_path = req["result_path"]
|
|
39
|
+
test_key = get(req, "test_key", "")
|
|
40
|
+
|
|
41
|
+
t_start = time()
|
|
42
|
+
try
|
|
43
|
+
# review 2026-07-06 (finding 27): in this long-lived process a user
|
|
44
|
+
# file that fails to (re)define build_mtk_system would silently reuse
|
|
45
|
+
# the PREVIOUS test's definition. Poison the binding with a sentinel
|
|
46
|
+
# method BEFORE every include — if the file doesn't replace it, the
|
|
47
|
+
# invokelatest below raises this error instead of simulating the
|
|
48
|
+
# wrong model (covers bad-file-after-good-file, not just the first).
|
|
49
|
+
Main.eval(:(build_mtk_system() = error(
|
|
50
|
+
"user file did not define build_mtk_system() — stale-definition guard"
|
|
51
|
+
)))
|
|
52
|
+
include(user_file)
|
|
53
|
+
nt = Base.invokelatest(build_mtk_system)
|
|
54
|
+
prob = ModelingToolkit.ODEProblem(nt.sys, nt.u0, (0.0, stop_time), nt.ps)
|
|
55
|
+
sol = OrdinaryDiffEq.solve(prob, OrdinaryDiffEq.Tsit5(); reltol=tol)
|
|
56
|
+
|
|
57
|
+
# review 2026-07-06 (finding 4): mirror run_test.jl — a diverged /
|
|
58
|
+
# truncated solve must not be reported (or baselined) as success.
|
|
59
|
+
if !OrdinaryDiffEq.SciMLBase.successful_retcode(sol)
|
|
60
|
+
error("solver did not succeed: retcode=" * string(sol.retcode))
|
|
61
|
+
end
|
|
62
|
+
if isempty(sol.t) || abs(sol.t[end] - stop_time) > 1e-6 * max(abs(stop_time), 1.0)
|
|
63
|
+
t_end = isempty(sol.t) ? "none" : string(sol.t[end])
|
|
64
|
+
error("solve stopped at t=" * t_end * " before stop_time=" *
|
|
65
|
+
string(stop_time) * " (retcode=" * string(sol.retcode) * ")")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Collect unknowns AND observables — see run_test.jl for rationale.
|
|
69
|
+
unk = ModelingToolkit.unknowns(nt.sys)
|
|
70
|
+
obs_eqs = ModelingToolkit.observed(nt.sys)
|
|
71
|
+
variables = Dict{String, Vector{Float64}}()
|
|
72
|
+
for u in unk
|
|
73
|
+
name = string(u)
|
|
74
|
+
stripped = endswith(name, "(t)") ? name[1:end-3] : name
|
|
75
|
+
variables[stripped] = Float64.(sol[u])
|
|
76
|
+
end
|
|
77
|
+
for eq in obs_eqs
|
|
78
|
+
lhs = eq.lhs
|
|
79
|
+
name = string(lhs)
|
|
80
|
+
stripped = endswith(name, "(t)") ? name[1:end-3] : name
|
|
81
|
+
if haskey(variables, stripped) || startswith(stripped, "ˍ")
|
|
82
|
+
continue
|
|
83
|
+
end
|
|
84
|
+
try
|
|
85
|
+
variables[stripped] = Float64.(sol[lhs])
|
|
86
|
+
catch
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
payload = Dict(
|
|
91
|
+
"success" => true,
|
|
92
|
+
"time" => Float64.(sol.t),
|
|
93
|
+
"variables" => variables,
|
|
94
|
+
)
|
|
95
|
+
open(result_path, "w") do io
|
|
96
|
+
JSON3.write(io, payload)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
elapsed = time() - t_start
|
|
100
|
+
println(stdout, JSON3.write(Dict(
|
|
101
|
+
"test_key" => test_key,
|
|
102
|
+
"status" => "ok",
|
|
103
|
+
"elapsed" => elapsed,
|
|
104
|
+
"n_vars" => length(variables),
|
|
105
|
+
"n_time" => length(sol.t),
|
|
106
|
+
)))
|
|
107
|
+
flush(stdout)
|
|
108
|
+
catch e
|
|
109
|
+
elapsed = time() - t_start
|
|
110
|
+
buf = IOBuffer()
|
|
111
|
+
showerror(buf, e, catch_backtrace())
|
|
112
|
+
msg = String(take!(buf))
|
|
113
|
+
# Best-effort failure payload so the backend's read_result path still
|
|
114
|
+
# gets a structured response.
|
|
115
|
+
try
|
|
116
|
+
open(result_path, "w") do io
|
|
117
|
+
JSON3.write(io, Dict(
|
|
118
|
+
"success" => false,
|
|
119
|
+
"error" => msg,
|
|
120
|
+
"time" => Float64[],
|
|
121
|
+
"variables" => Dict{String, Vector{Float64}}(),
|
|
122
|
+
))
|
|
123
|
+
end
|
|
124
|
+
catch
|
|
125
|
+
end
|
|
126
|
+
println(stdout, JSON3.write(Dict(
|
|
127
|
+
"test_key" => test_key,
|
|
128
|
+
"status" => "fail",
|
|
129
|
+
"elapsed" => elapsed,
|
|
130
|
+
"error" => msg,
|
|
131
|
+
)))
|
|
132
|
+
flush(stdout)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Main loop.
|
|
137
|
+
while !eof(stdin)
|
|
138
|
+
line = try
|
|
139
|
+
readline(stdin)
|
|
140
|
+
catch e
|
|
141
|
+
break
|
|
142
|
+
end
|
|
143
|
+
isempty(line) && continue
|
|
144
|
+
req = try
|
|
145
|
+
JSON3.read(line)
|
|
146
|
+
catch e
|
|
147
|
+
println(stderr, "Failed to parse request line: ", line)
|
|
148
|
+
continue
|
|
149
|
+
end
|
|
150
|
+
# Control messages first.
|
|
151
|
+
cmd = get(req, "cmd", "")
|
|
152
|
+
if cmd == "quit"
|
|
153
|
+
println(stdout, JSON3.write(Dict("event" => "quitting")))
|
|
154
|
+
flush(stdout)
|
|
155
|
+
break
|
|
156
|
+
elseif cmd == "ready"
|
|
157
|
+
println(stdout, JSON3.write(Dict("event" => "ready", "pid" => getpid())))
|
|
158
|
+
flush(stdout)
|
|
159
|
+
continue
|
|
160
|
+
end
|
|
161
|
+
# Test request.
|
|
162
|
+
if !haskey(req, "user_file")
|
|
163
|
+
println(stderr, "Ignoring request with no user_file: ", line)
|
|
164
|
+
continue
|
|
165
|
+
end
|
|
166
|
+
handle_request(req)
|
|
167
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Framework-generated Julia driver for a single test.
|
|
2
|
+
#
|
|
3
|
+
# Invoked by JuliaBackend as:
|
|
4
|
+
# julia --project=<examples_project> run_test.jl <user_file> <stop_time> <tol> <result_path>
|
|
5
|
+
#
|
|
6
|
+
# The user file must export `build_mtk_system()` returning a NamedTuple with:
|
|
7
|
+
# sys :: ODESystem — structurally simplified
|
|
8
|
+
# u0 :: Vector — initial conditions (pairs of Symbol => value)
|
|
9
|
+
# ps :: Vector — parameter values (pairs of Symbol => value)
|
|
10
|
+
#
|
|
11
|
+
# Result file is JSON:
|
|
12
|
+
# { "success": bool,
|
|
13
|
+
# "time": [...],
|
|
14
|
+
# "variables": {"name": [...], ...},
|
|
15
|
+
# "error": "..." (only if success == false) }
|
|
16
|
+
#
|
|
17
|
+
# The wrapper intentionally catches exceptions so the framework always
|
|
18
|
+
# gets a structured result (success=false + error) rather than a
|
|
19
|
+
# process-level crash.
|
|
20
|
+
|
|
21
|
+
using Printf
|
|
22
|
+
|
|
23
|
+
const USER_FILE = ARGS[1]
|
|
24
|
+
const STOP_TIME = parse(Float64, ARGS[2])
|
|
25
|
+
const TOLERANCE = parse(Float64, ARGS[3])
|
|
26
|
+
const RESULT_PATH = ARGS[4]
|
|
27
|
+
|
|
28
|
+
# review 2026-07-06 (finding 75): the failure JSON is hand-built (JSON3 may
|
|
29
|
+
# not have loaded when the failure happens). Julia's repr()-style escaping
|
|
30
|
+
# is NOT JSON (`\$` is invalid) — real error detail was lost to parse
|
|
31
|
+
# errors. Minimal manual JSON string escaper instead.
|
|
32
|
+
function json_escape(s::AbstractString)
|
|
33
|
+
buf = IOBuffer()
|
|
34
|
+
for c in s
|
|
35
|
+
if c == '\\'
|
|
36
|
+
write(buf, "\\\\")
|
|
37
|
+
elseif c == '"'
|
|
38
|
+
write(buf, "\\\"")
|
|
39
|
+
elseif c == '\n'
|
|
40
|
+
write(buf, "\\n")
|
|
41
|
+
elseif c == '\r'
|
|
42
|
+
write(buf, "\\r")
|
|
43
|
+
elseif c == '\t'
|
|
44
|
+
write(buf, "\\t")
|
|
45
|
+
elseif c < ' '
|
|
46
|
+
write(buf, "\\u" * string(UInt32(c), base=16, pad=4))
|
|
47
|
+
else
|
|
48
|
+
write(buf, c)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
return String(take!(buf))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
function write_failure(msg)
|
|
55
|
+
open(RESULT_PATH, "w") do io
|
|
56
|
+
write(io, string("{\"success\": false, \"error\": \"",
|
|
57
|
+
json_escape(string(msg)),
|
|
58
|
+
"\", \"time\": [], \"variables\": {}}"))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
try
|
|
63
|
+
import ModelingToolkit
|
|
64
|
+
import OrdinaryDiffEq
|
|
65
|
+
import JSON3
|
|
66
|
+
|
|
67
|
+
# Load the user file. It should export `build_mtk_system()`.
|
|
68
|
+
include(USER_FILE)
|
|
69
|
+
isdefined(@__MODULE__, :build_mtk_system) || error(
|
|
70
|
+
"user file $(USER_FILE) must export build_mtk_system()")
|
|
71
|
+
|
|
72
|
+
nt = build_mtk_system()
|
|
73
|
+
haskey(nt, :sys) || error("build_mtk_system() must return NamedTuple with :sys")
|
|
74
|
+
|
|
75
|
+
u0 = haskey(nt, :u0) ? nt.u0 : []
|
|
76
|
+
ps = haskey(nt, :ps) ? nt.ps : []
|
|
77
|
+
|
|
78
|
+
prob = ModelingToolkit.ODEProblem(nt.sys, u0, (0.0, STOP_TIME), ps)
|
|
79
|
+
sol = OrdinaryDiffEq.solve(prob, OrdinaryDiffEq.Tsit5(); reltol=TOLERANCE)
|
|
80
|
+
|
|
81
|
+
# review 2026-07-06 (finding 4): a diverged / unstable / maxiters-
|
|
82
|
+
# truncated solve must not be written as success — with --accept the
|
|
83
|
+
# truncated trajectory would become the regression baseline. Check the
|
|
84
|
+
# retcode AND that integration actually reached stop_time.
|
|
85
|
+
if !OrdinaryDiffEq.SciMLBase.successful_retcode(sol)
|
|
86
|
+
error("solver did not succeed: retcode=" * string(sol.retcode))
|
|
87
|
+
end
|
|
88
|
+
if isempty(sol.t) || abs(sol.t[end] - STOP_TIME) > 1e-6 * max(abs(STOP_TIME), 1.0)
|
|
89
|
+
t_end = isempty(sol.t) ? "none" : string(sol.t[end])
|
|
90
|
+
error("solve stopped at t=" * t_end * " before stop_time=" *
|
|
91
|
+
string(STOP_TIME) * " (retcode=" * string(sol.retcode) * ")")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Collect unknowns (state variables) AND observables (algebraic
|
|
95
|
+
# expressions like `y ~ a*x1 + b*x2` that structural_simplify
|
|
96
|
+
# promotes out of the unknowns list but are still materializable
|
|
97
|
+
# from the solution via sol[expr]). Users authoring MTK tests
|
|
98
|
+
# typically expect both to be accessible.
|
|
99
|
+
unk = ModelingToolkit.unknowns(nt.sys)
|
|
100
|
+
obs_eqs = ModelingToolkit.observed(nt.sys)
|
|
101
|
+
variables = Dict{String, Vector{Float64}}()
|
|
102
|
+
for u in unk
|
|
103
|
+
name = string(u)
|
|
104
|
+
stripped = endswith(name, "(t)") ? name[1:end-3] : name
|
|
105
|
+
variables[stripped] = Float64.(sol[u])
|
|
106
|
+
end
|
|
107
|
+
for eq in obs_eqs
|
|
108
|
+
lhs = eq.lhs
|
|
109
|
+
name = string(lhs)
|
|
110
|
+
stripped = endswith(name, "(t)") ? name[1:end-3] : name
|
|
111
|
+
# Skip if already captured as an unknown (shouldn't happen but
|
|
112
|
+
# defensive) or if the name is a helper symbol MTK introduced.
|
|
113
|
+
if haskey(variables, stripped) || startswith(stripped, "ˍ")
|
|
114
|
+
continue
|
|
115
|
+
end
|
|
116
|
+
try
|
|
117
|
+
variables[stripped] = Float64.(sol[lhs])
|
|
118
|
+
catch
|
|
119
|
+
# Some observables can't be evaluated (e.g., dependent on
|
|
120
|
+
# parameters only); skip silently rather than fail the test.
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
payload = Dict(
|
|
125
|
+
"success" => true,
|
|
126
|
+
"time" => Float64.(sol.t),
|
|
127
|
+
"variables" => variables,
|
|
128
|
+
)
|
|
129
|
+
open(RESULT_PATH, "w") do io
|
|
130
|
+
JSON3.write(io, payload)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
@printf "OK: %d variables, %d time points\n" length(variables) length(sol.t)
|
|
134
|
+
catch e
|
|
135
|
+
buf = IOBuffer()
|
|
136
|
+
showerror(buf, e, catch_backtrace())
|
|
137
|
+
msg = String(take!(buf))
|
|
138
|
+
@printf stderr "FAIL: %s\n" msg
|
|
139
|
+
try
|
|
140
|
+
write_failure(msg)
|
|
141
|
+
catch
|
|
142
|
+
# If even writing the failure JSON fails, at least exit nonzero.
|
|
143
|
+
exit(2)
|
|
144
|
+
end
|
|
145
|
+
exit(1)
|
|
146
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""OpenModelica backend.
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
|
|
5
|
+
- Persistent-worker (default CLI path): long-lived OMCSessionZMQ per worker
|
|
6
|
+
via OMPython (:mod:`.persistent_backend`). Cuts wall-time for repeated-test
|
|
7
|
+
runs since MSL and library loads are amortized across the whole batch.
|
|
8
|
+
- Batch fallback (``--batch``): one ``omc`` subprocess per test driven by a
|
|
9
|
+
generated ``simulate.mos`` (:mod:`.backend`). Used automatically when
|
|
10
|
+
OMPython is unavailable.
|
|
11
|
+
|
|
12
|
+
FMU export (``buildModelFMU``) is still a deferred follow-up.
|
|
13
|
+
|
|
14
|
+
One-time bootstrap (per machine) to install the Modelica Standard Library:
|
|
15
|
+
|
|
16
|
+
omc -e 'updatePackageIndex(); installPackage(Modelica); getErrorString();'
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .backend import OpenModelicaBackend, OpenModelicaConfig
|
|
20
|
+
|
|
21
|
+
__all__ = ["OpenModelicaConfig", "OpenModelicaBackend"]
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""OpenModelica backend: omc subprocess + .mos scripts (batch fallback).
|
|
2
|
+
|
|
3
|
+
Per-test omc subprocess driven by a generated ``simulate.mos``. The
|
|
4
|
+
persistent-worker counterpart lives in :mod:`.persistent_backend` and is the
|
|
5
|
+
default CLI path; this one is reached with the ``--batch`` CLI flag. There is
|
|
6
|
+
no automatic fallback — a missing OMPython aborts the run (see
|
|
7
|
+
``backends.select_backend``). FMU export (``buildModelFMU``) is still a follow-up.
|
|
8
|
+
|
|
9
|
+
One-time per machine (bootstrap MSL):
|
|
10
|
+
|
|
11
|
+
omc -e 'updatePackageIndex(); installPackage(Modelica); getErrorString();'
|
|
12
|
+
|
|
13
|
+
``backend_setup`` commands run between library-loading and ``cd()``. These
|
|
14
|
+
are emitted as-is and are backend-specific (Dymola-syntactic commands like
|
|
15
|
+
``Advanced.UI.TranslationInCommandLog := true`` will fail on omc). Users
|
|
16
|
+
maintain a separate ``testing.linux.json`` for the OpenModelica run.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ...config import Config
|
|
29
|
+
from ...discovery.test_registry import TestModel
|
|
30
|
+
from .. import register
|
|
31
|
+
from ..base import Backend
|
|
32
|
+
from ..common import result_files
|
|
33
|
+
from ..common.mat_reader import mat_reaches_time, read_result_mat
|
|
34
|
+
from ..common.proc_output import decode_output
|
|
35
|
+
from ..common.result_variables import (
|
|
36
|
+
compute_needed_variables,
|
|
37
|
+
extract_variables,
|
|
38
|
+
fold_diagnostics_into_statistics,
|
|
39
|
+
unfinished_result,
|
|
40
|
+
)
|
|
41
|
+
from ..types import (
|
|
42
|
+
Capability,
|
|
43
|
+
DatasetType,
|
|
44
|
+
TestResult,
|
|
45
|
+
TestRunResult,
|
|
46
|
+
timeout_message,
|
|
47
|
+
)
|
|
48
|
+
from .init_xml import with_system_size
|
|
49
|
+
from .log_parser import parse_omc_stdout
|
|
50
|
+
from .mos_generator import build_simulate_mos
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class OpenModelicaConfig:
|
|
57
|
+
"""OpenModelica-specific settings extracted from the universal Config."""
|
|
58
|
+
|
|
59
|
+
omc_path: str
|
|
60
|
+
backend_setup: tuple[str, ...] = ()
|
|
61
|
+
diagnostic_variables: tuple[str, ...] = ("CPUtime", "EventCounter")
|
|
62
|
+
std_version: str = "latest"
|
|
63
|
+
#: Write each output row to the result file as it is produced
|
|
64
|
+
#: (``Config.live_results``).
|
|
65
|
+
mat_sync: bool = False
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(cls, config: Config) -> OpenModelicaConfig:
|
|
69
|
+
# Config.__post_init__ now resolves backend_path via
|
|
70
|
+
# BACKEND_BINARY_NAMES, so the Config-supplied value is authoritative.
|
|
71
|
+
# Fall through to `omc` on PATH only if the config didn't set one at
|
|
72
|
+
# all (e.g., stubbed tests that bypass __post_init__).
|
|
73
|
+
omc_path = config.backend_path or shutil.which("omc") or "omc"
|
|
74
|
+
return cls(
|
|
75
|
+
omc_path=omc_path,
|
|
76
|
+
backend_setup=tuple(config.backend_setup),
|
|
77
|
+
diagnostic_variables=tuple(config.diagnostic_variables),
|
|
78
|
+
mat_sync=config.live_results,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@register("OpenModelica")
|
|
83
|
+
class OpenModelicaBackend(Backend):
|
|
84
|
+
"""OpenModelica backend using omc as a subprocess driver."""
|
|
85
|
+
|
|
86
|
+
capabilities = frozenset(
|
|
87
|
+
{
|
|
88
|
+
Capability.BATCH_FALLBACK,
|
|
89
|
+
Capability.PERSISTENT_WORKERS,
|
|
90
|
+
# The simulation executable appends result_res.mat as it goes:
|
|
91
|
+
# in blocks, or a row at a time under `-mat_sync=1`.
|
|
92
|
+
Capability.LIVE_RESULTS,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
produced_datasets = frozenset({DatasetType.TIME_SERIES})
|
|
96
|
+
artifact_files = (
|
|
97
|
+
("simulate.mos", "Simulation script"),
|
|
98
|
+
("result_res.mat", "Result file"),
|
|
99
|
+
("result.log", "Simulation log"),
|
|
100
|
+
("result_info.json", "Model info"),
|
|
101
|
+
("omc_stdout.txt", "omc output"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
RESULT_MAT_FILENAME = "result_res.mat"
|
|
105
|
+
INIT_XML_FILENAME = "result_init.xml"
|
|
106
|
+
STDOUT_FILENAME = "omc_stdout.txt"
|
|
107
|
+
MOS_FILENAME = "simulate.mos"
|
|
108
|
+
|
|
109
|
+
def __init__(self, config: Config):
|
|
110
|
+
super().__init__(config)
|
|
111
|
+
self.om_config = OpenModelicaConfig.from_config(config)
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def persistent_backend_cls(cls):
|
|
115
|
+
from .persistent_backend import PersistentOpenModelicaBackend
|
|
116
|
+
|
|
117
|
+
return PersistentOpenModelicaBackend
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Simulation
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def run_single_test(
|
|
124
|
+
self,
|
|
125
|
+
test: TestModel,
|
|
126
|
+
test_key: str,
|
|
127
|
+
index: int,
|
|
128
|
+
total: int,
|
|
129
|
+
) -> TestRunResult:
|
|
130
|
+
test_dir = self.config.work_dir / test_key
|
|
131
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
if self.progress:
|
|
134
|
+
self.progress.on_start(test_key)
|
|
135
|
+
self.progress.on_phase(test_key, "translating")
|
|
136
|
+
|
|
137
|
+
# MSL is always required for Modelica simulation. Dymola auto-loads
|
|
138
|
+
# it; OM needs an explicit loadModel(Modelica). Auto-inject if the
|
|
139
|
+
# user didn't already list it, so a single testing.json with an
|
|
140
|
+
# empty ``dependencies`` works across both backends.
|
|
141
|
+
deps = list(self.config.dependencies)
|
|
142
|
+
if "Modelica" not in deps and not any(
|
|
143
|
+
str(d).rstrip("/\\").endswith("Modelica") for d in deps
|
|
144
|
+
):
|
|
145
|
+
deps = ["Modelica"] + deps
|
|
146
|
+
|
|
147
|
+
# Build and write the .mos
|
|
148
|
+
mos_text = build_simulate_mos(
|
|
149
|
+
test=test,
|
|
150
|
+
test_dir=test_dir,
|
|
151
|
+
library_package_mo=self.config.library_dir / "package.mo",
|
|
152
|
+
dependencies=deps,
|
|
153
|
+
backend_setup=list(self.om_config.backend_setup),
|
|
154
|
+
diagnostic_vars=list(self.om_config.diagnostic_variables),
|
|
155
|
+
std_version=self.om_config.std_version,
|
|
156
|
+
mat_sync=self.om_config.mat_sync,
|
|
157
|
+
)
|
|
158
|
+
mos_path = test_dir / self.MOS_FILENAME
|
|
159
|
+
mos_path.write_text(mos_text, encoding="utf-8")
|
|
160
|
+
|
|
161
|
+
timeout = float(
|
|
162
|
+
test.timeout if test.timeout is not None else self.config.timeout,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
wall_start = time.monotonic()
|
|
166
|
+
try:
|
|
167
|
+
proc = subprocess.run(
|
|
168
|
+
[self.om_config.omc_path, self.MOS_FILENAME],
|
|
169
|
+
cwd=test_dir,
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
# review 2026-07-06 (finding 75): pin utf-8 + replace so a
|
|
173
|
+
# non-ASCII omc message can't crash the reader on cp1252 hosts.
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
errors="replace",
|
|
176
|
+
timeout=timeout,
|
|
177
|
+
)
|
|
178
|
+
except subprocess.TimeoutExpired as exc:
|
|
179
|
+
elapsed = time.monotonic() - wall_start
|
|
180
|
+
# review 2026-07-06 (finding 23): TimeoutExpired.stdout is bytes
|
|
181
|
+
# even with text=True — decode_output handles None/bytes/str.
|
|
182
|
+
(test_dir / self.STDOUT_FILENAME).write_text(
|
|
183
|
+
decode_output(exc.stdout) + "\n[TimeoutExpired]\n",
|
|
184
|
+
encoding="utf-8",
|
|
185
|
+
)
|
|
186
|
+
msg = timeout_message(f"omc exceeded {timeout}s timeout")
|
|
187
|
+
logger.warning("Test %s: %s", test.model_id, msg)
|
|
188
|
+
if self.progress:
|
|
189
|
+
self.progress.on_finish(
|
|
190
|
+
test_key,
|
|
191
|
+
success=False,
|
|
192
|
+
elapsed=elapsed,
|
|
193
|
+
detail=msg,
|
|
194
|
+
timed_out=True,
|
|
195
|
+
)
|
|
196
|
+
return TestRunResult(
|
|
197
|
+
model_id=test.model_id,
|
|
198
|
+
test_key=test_key,
|
|
199
|
+
success=False,
|
|
200
|
+
elapsed=elapsed,
|
|
201
|
+
error_message=msg,
|
|
202
|
+
timed_out=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
elapsed = time.monotonic() - wall_start
|
|
206
|
+
stdout_text = proc.stdout or ""
|
|
207
|
+
(test_dir / self.STDOUT_FILENAME).write_text(stdout_text, encoding="utf-8")
|
|
208
|
+
|
|
209
|
+
parsed = parse_omc_stdout(stdout_text)
|
|
210
|
+
|
|
211
|
+
# Build statistics.timing — wall-clock seconds captured Python-side.
|
|
212
|
+
stats: dict = {}
|
|
213
|
+
if parsed.timings is not None:
|
|
214
|
+
stats["timing"] = dict(parsed.timings)
|
|
215
|
+
|
|
216
|
+
# Translation-wall = sum of all pre-simulation phases.
|
|
217
|
+
translation_wall: float | None = None
|
|
218
|
+
sim_wall: float | None = None
|
|
219
|
+
if parsed.timings is not None:
|
|
220
|
+
t = parsed.timings
|
|
221
|
+
translation_wall = (
|
|
222
|
+
t.get("frontend", 0.0)
|
|
223
|
+
+ t.get("backend", 0.0)
|
|
224
|
+
+ t.get("simcode", 0.0)
|
|
225
|
+
+ t.get("templates", 0.0)
|
|
226
|
+
+ t.get("compile", 0.0)
|
|
227
|
+
)
|
|
228
|
+
sim_wall = t.get("simulation")
|
|
229
|
+
|
|
230
|
+
mat_path = test_dir / self.RESULT_MAT_FILENAME
|
|
231
|
+
if parsed.success and mat_path.exists():
|
|
232
|
+
# Belt on the accept-integrity invariant (D93 precedent): a
|
|
233
|
+
# partial .mat blessed by --accept poisons the baseline, so
|
|
234
|
+
# even a success-reporting record must show a trajectory that
|
|
235
|
+
# reached stop_time. Empirically (D99, omc 1.27) mid-run
|
|
236
|
+
# aborts return an EMPTY resultFile and never take this
|
|
237
|
+
# branch — this guards against a future omc populating
|
|
238
|
+
# resultFile on partial runs or a truncated write.
|
|
239
|
+
reached, last_time = mat_reaches_time(mat_path, float(test.stop_time))
|
|
240
|
+
if reached:
|
|
241
|
+
if self.progress:
|
|
242
|
+
self.progress.on_phase(test_key, "simulating")
|
|
243
|
+
self.progress.on_finish(test_key, success=True, elapsed=elapsed)
|
|
244
|
+
return TestRunResult(
|
|
245
|
+
model_id=test.model_id,
|
|
246
|
+
test_key=test_key,
|
|
247
|
+
success=True,
|
|
248
|
+
elapsed=elapsed,
|
|
249
|
+
translation_wall=translation_wall,
|
|
250
|
+
sim_wall=sim_wall,
|
|
251
|
+
statistics=stats or None,
|
|
252
|
+
)
|
|
253
|
+
parsed.error_notices.append(
|
|
254
|
+
f"simulate() reported success but {self.RESULT_MAT_FILENAME} "
|
|
255
|
+
f"reaches t={last_time!r} of stop_time={test.stop_time} — "
|
|
256
|
+
f"partial result refused"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Failure: build an error message that surfaces both pre-record
|
|
260
|
+
# notices and the simulate() record's `messages` field.
|
|
261
|
+
err_parts: list[str] = []
|
|
262
|
+
if proc.returncode != 0:
|
|
263
|
+
err_parts.append(f"omc exit code {proc.returncode}")
|
|
264
|
+
if parsed.error_notices:
|
|
265
|
+
err_parts.append("; ".join(parsed.error_notices[:5]))
|
|
266
|
+
if parsed.messages:
|
|
267
|
+
err_parts.append(parsed.messages.strip())
|
|
268
|
+
if not parsed.result_file:
|
|
269
|
+
err_parts.append("no result file produced")
|
|
270
|
+
msg = " | ".join(p for p in err_parts if p)[:2048] or "omc simulation failed"
|
|
271
|
+
|
|
272
|
+
# If MSL wasn't installed, surface a clear hint.
|
|
273
|
+
if any("Failed to load package Modelica" in n for n in parsed.error_notices):
|
|
274
|
+
msg = (
|
|
275
|
+
"MSL not installed. Run: omc -e "
|
|
276
|
+
"'updatePackageIndex(); installPackage(Modelica); "
|
|
277
|
+
"getErrorString();' | " + msg
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if self.progress:
|
|
281
|
+
self.progress.on_finish(
|
|
282
|
+
test_key,
|
|
283
|
+
success=False,
|
|
284
|
+
elapsed=elapsed,
|
|
285
|
+
detail=msg[:120],
|
|
286
|
+
)
|
|
287
|
+
return TestRunResult(
|
|
288
|
+
model_id=test.model_id,
|
|
289
|
+
test_key=test_key,
|
|
290
|
+
success=False,
|
|
291
|
+
elapsed=elapsed,
|
|
292
|
+
error_message=msg,
|
|
293
|
+
translation_wall=translation_wall,
|
|
294
|
+
sim_wall=sim_wall,
|
|
295
|
+
statistics=stats or None,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Reading
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def read_result(
|
|
303
|
+
self,
|
|
304
|
+
test: TestModel,
|
|
305
|
+
test_key: str,
|
|
306
|
+
run_result: TestRunResult | None,
|
|
307
|
+
) -> TestResult:
|
|
308
|
+
stats = (
|
|
309
|
+
dict(run_result.statistics)
|
|
310
|
+
if run_result and run_result.statistics
|
|
311
|
+
else None
|
|
312
|
+
)
|
|
313
|
+
test_dir = self.config.work_dir / test_key
|
|
314
|
+
mat_path = test_dir / self.RESULT_MAT_FILENAME
|
|
315
|
+
|
|
316
|
+
if not mat_path.exists():
|
|
317
|
+
msg = (
|
|
318
|
+
run_result.error_message if run_result else None
|
|
319
|
+
) or f"Result file not found: {mat_path}"
|
|
320
|
+
return TestResult(
|
|
321
|
+
model_id=test.model_id,
|
|
322
|
+
success=False,
|
|
323
|
+
error_message=msg,
|
|
324
|
+
statistics=stats,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
diag_names = list(self.om_config.diagnostic_variables)
|
|
328
|
+
needed = compute_needed_variables(mat_path, test, diag_names)
|
|
329
|
+
|
|
330
|
+
mat_data = read_result_mat(mat_path, variable_names=needed)
|
|
331
|
+
if mat_data is None:
|
|
332
|
+
return TestResult(
|
|
333
|
+
model_id=test.model_id,
|
|
334
|
+
success=False,
|
|
335
|
+
error_message=f"Failed to parse: {mat_path}",
|
|
336
|
+
statistics=stats,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
variables, diagnostics = extract_variables(mat_data, test, diag_names)
|
|
340
|
+
stats = fold_diagnostics_into_statistics(stats, diagnostics)
|
|
341
|
+
# The system's size, from the file omc generated with the result
|
|
342
|
+
# (`init_xml`): here, so the persistent workers, `--batch` and a
|
|
343
|
+
# `compare` of an earlier run all record it.
|
|
344
|
+
stats = with_system_size(stats, test_dir / self.INIT_XML_FILENAME)
|
|
345
|
+
|
|
346
|
+
unfinished = unfinished_result(test, mat_path, variables, diagnostics, stats)
|
|
347
|
+
if unfinished is not None:
|
|
348
|
+
return unfinished
|
|
349
|
+
|
|
350
|
+
return TestResult(
|
|
351
|
+
model_id=test.model_id,
|
|
352
|
+
success=True,
|
|
353
|
+
variables=variables,
|
|
354
|
+
diagnostics=diagnostics,
|
|
355
|
+
statistics=stats,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def result_names(cls, test_dir: Path) -> list[str] | None:
|
|
360
|
+
return result_files.mat_names(test_dir / cls.RESULT_MAT_FILENAME)
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def result_series(cls, test_dir, names):
|
|
364
|
+
return result_files.mat_series(test_dir / cls.RESULT_MAT_FILENAME, names)
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def live_result_path(cls, test_dir: Path) -> Path:
|
|
368
|
+
return test_dir / cls.RESULT_MAT_FILENAME
|