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,76 @@
1
+ """What omc's ``<prefix>_init.xml`` says of a model's SIZE.
2
+
3
+ OpenModelica prints no translation statistics, so its statistics were
4
+ timing only: a model that gained a state said ``Continuous states: 2 -> 3``
5
+ on Dymola (its translation log) and nothing here. omc does write the
6
+ system's size, as attributes of the root element of the init file it
7
+ generates beside the executable (``fmiModelDescription
8
+ numberOfContinuousStates="3" …``). What that file holds of a quantity
9
+ Dymola's backend records is read into the SAME statistics key — one
10
+ structural check, one Statistics row, on both simulators.
11
+
12
+ Read from the file, at every read of a result: nothing is kept beside the
13
+ result for it, so a ``compare`` of an earlier run has it too. A baseline
14
+ accepted before this has no such statistic, and a statistic one side lacks
15
+ is no change (``comparator._check_structural_changes``, ``stats_rows.
16
+ changed``): the first accept after it records the number.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import re
23
+ import xml.etree.ElementTree as ET
24
+ from pathlib import Path
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ #: omc's attribute → the ``statistics.translation`` key Dymola's translation
29
+ #: log fills with the same quantity. Only what both simulators count the
30
+ #: same way: omc's other counts (event indicators, algebraic variables by
31
+ #: type, parameters by type) have no twin there — Dymola's ``free_parameters``
32
+ #: and ``scalar_unknowns`` are other quantities — and are not read.
33
+ SYSTEM_SIZE = {"numberOfContinuousStates": "continuous_time_states"}
34
+
35
+ #: A count as omc writes one: ASCII digits and nothing else.
36
+ _COUNT = re.compile(r"[0-9]+")
37
+
38
+
39
+ def system_size(init_xml: Path) -> dict[str, int]:
40
+ """The ``statistics.translation`` entries *init_xml* gives; none for a
41
+ file that is not there, does not parse, or holds no count under an
42
+ attribute — an old omc, a build that failed before it wrote the file.
43
+ ``0`` is a count: a model with no state has one. Only the root element's start tag is read: the file lists every
44
+ variable of the model, megabytes for a large one."""
45
+ try:
46
+ for _event, root in ET.iterparse(init_xml, events=("start",)):
47
+ attributes = dict(root.attrib)
48
+ break
49
+ else:
50
+ return {}
51
+ except (OSError, ET.ParseError):
52
+ logger.debug("no system size from %s", init_xml, exc_info=True)
53
+ return {}
54
+ found: dict[str, int] = {}
55
+ for attribute, key in SYSTEM_SIZE.items():
56
+ text = attributes.get(attribute)
57
+ # A count is a non-negative decimal integer, all of it — or it is
58
+ # not recorded. `int()` also reads `-1`, `+3`, `1_000`, ` 3 ` and
59
+ # the digits of other scripts, none of which omc writes for one.
60
+ if text is not None and _COUNT.fullmatch(text):
61
+ found[key] = int(text)
62
+ return found
63
+
64
+
65
+ def with_system_size(stats: dict | None, init_xml: Path) -> dict | None:
66
+ """*stats* with what *init_xml* says under ``translation``: a new dict,
67
+ or *stats* itself — None included — when the file says nothing."""
68
+ found = system_size(init_xml)
69
+ if not found:
70
+ return stats
71
+ merged = dict(stats or {})
72
+ merged["translation"] = {**(merged.get("translation") or {}), **found}
73
+ return merged
74
+
75
+
76
+ __all__ = ["SYSTEM_SIZE", "system_size", "with_system_size"]
@@ -0,0 +1,119 @@
1
+ """Parse OpenModelica's ``omc`` stdout for per-test results.
2
+
3
+ When ``omc`` runs a ``.mos`` that ends with a top-level ``simulate(...)`` call,
4
+ it REPL-echoes the returned record to stdout:
5
+
6
+ record SimulationResult
7
+ resultFile = "/path/to/result_res.mat",
8
+ simulationOptions = "...",
9
+ messages = "...",
10
+ timeFrontend = 0.19,
11
+ timeBackend = 0.04,
12
+ ...
13
+ timeTotal = 1.25
14
+ end SimulationResult;
15
+
16
+ This module extracts that block, classifies success/failure, and surfaces any
17
+ Error/Warning/Notification lines that appeared BEFORE it. The return shape is
18
+ a small ``ParsedOmcOutput`` dataclass the backend consumes to populate
19
+ ``TestRunResult``.
20
+
21
+ The parser is total — any shape of input produces a ``ParsedOmcOutput``, with
22
+ ``success=False`` and ``timings=None`` on truncation / missing record.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from dataclasses import dataclass, field
29
+
30
+
31
+ @dataclass
32
+ class ParsedOmcOutput:
33
+ """Structured view of what the backend needs from omc stdout."""
34
+
35
+ success: bool
36
+ result_file: str = ""
37
+ messages: str = ""
38
+ timings: dict[str, float] | None = None
39
+ error_notices: list[str] = field(default_factory=list)
40
+
41
+
42
+ # Match 'record SimulationResult … end SimulationResult;' lazily across
43
+ # however many lines the record spans.
44
+ _RECORD_RE = re.compile(
45
+ r"record SimulationResult(?P<body>.*?)end SimulationResult;",
46
+ re.DOTALL,
47
+ )
48
+
49
+ # Match a quoted string-valued record field. The value may span multiple
50
+ # lines (OM's 'messages' often does). Handles embedded \" and \\ escapes.
51
+ _STR_FIELD_RE = re.compile(
52
+ r'\b(\w+)\s*=\s*"((?:\\.|[^"\\])*)"',
53
+ re.DOTALL,
54
+ )
55
+
56
+ # Match a numeric-valued record field. Run AFTER stripping string fields from
57
+ # the body (digits in messages would otherwise get misread).
58
+ _NUM_FIELD_RE = re.compile(
59
+ r"\b(\w+)\s*=\s*([-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)",
60
+ )
61
+
62
+ # OM field name -> our internal key
63
+ _TIMING_KEYS = {
64
+ "timeFrontend": "frontend",
65
+ "timeBackend": "backend",
66
+ "timeSimCode": "simcode",
67
+ "timeTemplates": "templates",
68
+ "timeCompile": "compile",
69
+ "timeSimulation": "simulation",
70
+ "timeTotal": "total",
71
+ }
72
+
73
+ # Lines like 'Error: ...' / 'Warning: ...' / 'Notification: ...'
74
+ _NOTICE_RE = re.compile(
75
+ r"^\s*(?:Error|Warning|Notification)\b[: ].*$",
76
+ re.MULTILINE,
77
+ )
78
+
79
+
80
+ def parse_omc_stdout(text: str) -> ParsedOmcOutput:
81
+ """Parse captured omc stdout into a ``ParsedOmcOutput``."""
82
+ m = _RECORD_RE.search(text)
83
+ preamble = text if not m else text[: m.start()]
84
+ error_notices = [mo.group(0).strip() for mo in _NOTICE_RE.finditer(preamble)]
85
+
86
+ if not m:
87
+ return ParsedOmcOutput(success=False, error_notices=error_notices)
88
+
89
+ body = m.group("body")
90
+
91
+ # Extract string-valued fields first (they may contain digits we don't
92
+ # want to misinterpret as timings).
93
+ str_fields: dict[str, str] = {}
94
+ for fm in _STR_FIELD_RE.finditer(body):
95
+ str_fields[fm.group(1)] = fm.group(2)
96
+
97
+ # Strip strings from the body before pulling numbers.
98
+ body_no_strings = _STR_FIELD_RE.sub("", body)
99
+ timings: dict[str, float] = {}
100
+ for fm in _NUM_FIELD_RE.finditer(body_no_strings):
101
+ name = fm.group(1)
102
+ if name in _TIMING_KEYS:
103
+ try:
104
+ timings[_TIMING_KEYS[name]] = float(fm.group(2))
105
+ except ValueError:
106
+ pass
107
+
108
+ result_file = str_fields.get("resultFile", "")
109
+ messages = str_fields.get("messages", "")
110
+
111
+ success = bool(result_file) and "Failed" not in messages
112
+
113
+ return ParsedOmcOutput(
114
+ success=success,
115
+ result_file=result_file,
116
+ messages=messages,
117
+ timings=timings or None,
118
+ error_notices=error_notices,
119
+ )
@@ -0,0 +1,228 @@
1
+ """Pure-text builders for OpenModelica .mos scripts.
2
+
3
+ No I/O beyond string assembly — trivially unit-testable. The backend calls
4
+ :func:`build_simulate_mos` per test, writes the result to
5
+ ``<test_dir>/simulate.mos``, then invokes ``omc`` on it.
6
+
7
+ The .mos does NOT emit explicit timing output. When ``omc`` runs a script
8
+ non-interactively it REPL-echoes the top-level ``simulate(...)`` call as a
9
+ ``record SimulationResult ... end SimulationResult;`` block that already
10
+ contains ``resultFile``, ``messages``, and the ``timeFrontend / Backend /
11
+ SimCode / Templates / Compile / Simulation / Total`` fields — everything we
12
+ need. ``log_parser.parse_omc_stdout`` pulls them from that block, so the
13
+ .mos stays small and the print/sentinel dance proved to be silently
14
+ unreliable (``res`` isn't in scope for the follow-up prints).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterable
21
+ from pathlib import Path
22
+
23
+ from ...discovery.library_state import dependency_file
24
+ from ...discovery.test_registry import TestModel
25
+ from ...discovery.unit_tests import unit_test_variables
26
+ from ..common.result_variables import criterion_variables
27
+ from ..types import _pattern_to_regex
28
+
29
+
30
+ def classify_dependency(entry: str) -> tuple[str, str]:
31
+ """Classify a Config.dependencies entry as loadModel vs loadFile.
32
+
33
+ Returns (``"loadModel"``, name) for bare library names (no path
34
+ separators, doesn't look like a file), or (``"loadFile"``,
35
+ absolute_path) for path-like entries (resolves to a package.mo).
36
+
37
+ Bare names reach this function unresolved because Config's dependency
38
+ absolutization skips them — review 2026-07-06 finding 17: absolutizing
39
+ "Modelica" produced ``loadFile()`` on a nonexistent path and suppressed
40
+ the MSL auto-injection.
41
+ """
42
+ # Path-like if it contains a separator, ends in .mo, or starts with a
43
+ # relative-path/home prefix (mirrors config._dependency_looks_like_path).
44
+ looks_like_path = (
45
+ "/" in entry
46
+ or "\\" in entry
47
+ or entry.endswith(".mo")
48
+ or entry.startswith(".")
49
+ or entry.startswith("~")
50
+ )
51
+ if not looks_like_path:
52
+ return ("loadModel", entry)
53
+
54
+ # The file it is loaded by: itself, or its directory's package.mo.
55
+ return ("loadFile", str(dependency_file(entry)).replace("\\", "/"))
56
+
57
+
58
+ def build_variable_filter(
59
+ patterns: Iterable[str],
60
+ diagnostic_vars: Iterable[str],
61
+ extra_names: Iterable[str] = (),
62
+ ) -> str:
63
+ """Build OM's ``variableFilter`` regex for the given tracked-variable set.
64
+
65
+ OM's ``variableFilter`` is a regex over variable names. We escape each
66
+ name literally and join with ``|``. ``time`` is always included; so are
67
+ the diagnostic variables and any ``extra_names`` the caller passes (the
68
+ UnitTests component's ``<instance>.x[1..N]`` for unit_tests-sourced
69
+ tests). Returning a regex (not ``.*``) keeps the .mat small — OM dumps
70
+ all parameters/aliases/derivatives by default.
71
+ """
72
+ alternatives: list[str] = []
73
+ # Always include time
74
+ alternatives.append(re.escape("time"))
75
+ # Tracked variables: expand globs (*, ?) to regex via the framework's
76
+ # existing glob-to-regex helper, so a pattern like ``pipe.T*`` becomes
77
+ # ``pipe\.T.*`` and the filter actually matches the runtime variable
78
+ # names (post-simulation ``resolve_variable_patterns`` still narrows
79
+ # the set for the pass/fail comparison).
80
+ for pat in patterns:
81
+ alternatives.append(_pattern_to_regex(pat).pattern)
82
+ # Extra literal names (no globs). build_simulate_args uses this for the
83
+ # UnitTests component's variables (``<instance>.x[i]``), which the spec
84
+ # doesn't list, and for the variables a criterion names.
85
+ for n in extra_names:
86
+ alternatives.append(re.escape(n))
87
+ # Diagnostic variables (literal names — no globs)
88
+ for dv in diagnostic_vars:
89
+ alternatives.append(re.escape(dv))
90
+ # Dedupe while preserving order
91
+ seen: set[str] = set()
92
+ unique: list[str] = []
93
+ for a in alternatives:
94
+ if a not in seen:
95
+ seen.add(a)
96
+ unique.append(a)
97
+ # OM's variableFilter is a POSIX ERE applied with partial-match semantics
98
+ # by default — an unanchored alternation would over-match (e.g. "x" would
99
+ # hit "phi" or "x_derivative"). Anchor with ^(...)$ to force whole-name
100
+ # match.
101
+ return "^(" + "|".join(unique) + ")$"
102
+
103
+
104
+ def _format_sim_kwarg(key: str, value) -> str:
105
+ """Render one key=value for OM's simulate() call."""
106
+ if isinstance(value, str):
107
+ # OM escape: double-quote and escape backslashes + quotes
108
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
109
+ return f'{key}="{escaped}"'
110
+ if isinstance(value, bool):
111
+ return f"{key}={'true' if value else 'false'}"
112
+ if isinstance(value, float):
113
+ # Avoid scientific notation for integer-valued floats, use repr otherwise.
114
+ if value == int(value):
115
+ return f"{key}={value!r}"
116
+ return f"{key}={value!r}"
117
+ return f"{key}={value!r}"
118
+
119
+
120
+ def build_simulate_args(
121
+ test: TestModel,
122
+ diagnostic_vars: Iterable[str],
123
+ *,
124
+ mat_sync: bool = False,
125
+ ) -> list[str]:
126
+ """Build the ``key=value`` strings for an OM ``simulate(...)`` call.
127
+
128
+ Pure string assembly; used both by :func:`build_simulate_mos` (batch
129
+ path) and by the persistent-worker backend, which passes the same args
130
+ to ``session.sendExpression("simulate(<model>, <args>)")``.
131
+
132
+ OM's ``simulate()`` has ``numberOfIntervals`` but not ``outputInterval``
133
+ (Dymola's ``simulateModel`` has the latter), so we convert
134
+ ``output_interval`` → ``numberOfIntervals`` when that's what the test
135
+ specified. Solver names are lowercased here so a framework-wide
136
+ Dymola-style ``"Dassl"`` survives the crossing.
137
+
138
+ ``mat_sync`` adds ``simflags="-mat_sync=1"``: the simulation executable
139
+ then writes each output row to the result file as it is produced, where
140
+ it otherwise leaves them in a buffer until a block is full. A run that
141
+ is watched (``Config.live_results``) asks for it; no other run does, so
142
+ with it off the arguments are exactly what they were before it existed.
143
+ """
144
+ diagnostic_vars = list(diagnostic_vars) # read twice below
145
+ args: list[str] = []
146
+ args.append(f"stopTime={float(test.stop_time)!r}")
147
+ if test.number_of_intervals is not None:
148
+ args.append(f"numberOfIntervals={int(test.number_of_intervals)}")
149
+ elif test.output_interval is not None and test.output_interval > 0:
150
+ n_intervals = max(
151
+ 1, int(round(float(test.stop_time) / float(test.output_interval)))
152
+ )
153
+ args.append(f"numberOfIntervals={n_intervals}")
154
+ args.append(f"tolerance={float(test.tolerance)!r}")
155
+ args.append(_format_sim_kwarg("method", (test.method or "dassl").lower()))
156
+ args.append('outputFormat="mat"')
157
+ args.append('fileNamePrefix="result"')
158
+ var_filter = build_variable_filter(
159
+ test.variable_patterns,
160
+ diagnostic_vars,
161
+ # The UnitTests component's variables are in no spec pattern: both
162
+ # OM backends would otherwise filter them out of the .mat. Naming
163
+ # them here is what keeps the two paths saying the same thing. The
164
+ # variables a criterion names follow them (tracked because scored).
165
+ extra_names=[
166
+ *(v.name for v in unit_test_variables(test)),
167
+ *criterion_variables(test, diagnostic_vars),
168
+ ],
169
+ )
170
+ args.append(_format_sim_kwarg("variableFilter", var_filter))
171
+ if mat_sync:
172
+ args.append(_format_sim_kwarg("simflags", "-mat_sync=1"))
173
+ return args
174
+
175
+
176
+ def build_simulate_mos(
177
+ *,
178
+ test: TestModel,
179
+ test_dir: Path,
180
+ library_package_mo: Path,
181
+ dependencies: list[str],
182
+ backend_setup: list[str],
183
+ diagnostic_vars: list[str],
184
+ std_version: str = "latest",
185
+ mat_sync: bool = False,
186
+ ) -> str:
187
+ """Assemble the full per-test .mos script.
188
+
189
+ See the module docstring for the shape. All path arguments are emitted
190
+ with forward slashes (OM accepts them on Windows too, and it sidesteps
191
+ Modelica string-escaping of backslashes).
192
+ """
193
+
194
+ def fwd(path: Path) -> str:
195
+ return str(path).replace("\\", "/")
196
+
197
+ lines: list[str] = []
198
+ lines.append(f'setCommandLineOptions("--std={std_version}");')
199
+
200
+ for dep in dependencies:
201
+ kind, arg = classify_dependency(dep)
202
+ if kind == "loadModel":
203
+ lines.append(f"loadModel({arg});")
204
+ else:
205
+ lines.append(f'loadFile("{arg}");')
206
+ lines.append("getErrorString();")
207
+
208
+ # Main library
209
+ lines.append(f'loadFile("{fwd(library_package_mo)}");')
210
+ lines.append("getErrorString();")
211
+
212
+ # Setup commands (backend-specific; user owns these)
213
+ for cmd in backend_setup:
214
+ c = cmd.strip()
215
+ if not c.endswith(";"):
216
+ c = c + ";"
217
+ lines.append(c)
218
+
219
+ lines.append(f'cd("{fwd(test_dir)}");')
220
+
221
+ sim_args = build_simulate_args(test, diagnostic_vars, mat_sync=mat_sync)
222
+ # Bare simulate(...) — omc REPL-echoes the SimulationResult record to
223
+ # stdout, which log_parser parses. No ``res := ...`` assignment and no
224
+ # follow-up prints (see module docstring).
225
+ lines.append(f"simulate({test.model_id}, {', '.join(sim_args)});")
226
+ lines.append("getErrorString();")
227
+
228
+ return "\n".join(lines) + "\n"