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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. 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